mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e16496450 | |||
| d28e23dd8e | |||
| d8145ac370 | |||
| fbc4a7fcbd | |||
| 04ecf14cc8 | |||
| 4eae1f1db3 | |||
| fd4ab0077d | |||
| d6c074102b | |||
| 2beab0c274 | |||
| 4f35b8ea0d | |||
| e4cbf0a34a | |||
| d79ce99629 | |||
| 8bf488de0b | |||
| d420319b28 | |||
| 413bcfa7de | |||
| 363f1fffca | |||
| 3e7b61c9d7 | |||
| ac0488a4d6 | |||
| 41af3a107e | |||
| 964ba78d75 | |||
| 340109d9a3 | |||
| 6c430dc747 | |||
| 93f12d8846 | |||
| a93655bf6e | |||
| e2b4fa456b | |||
| cd04c4a8bf | |||
| bf7fb8aa68 | |||
| 08a6376947 | |||
| a120427943 | |||
| 59e97eba2b | |||
| 80b59b1174 | |||
| 6a17e8deec | |||
| cd0aba119b | |||
| eca17ec63d | |||
| e164c4e7ca | |||
| bead9ae79a | |||
| 336e424b8b | |||
| 0bb993634a | |||
| 2f26e76b1e | |||
| 93a89eeef3 | |||
| 6e6a5014af | |||
| 3da1945bea | |||
| c2fbb31e77 | |||
| 4c999d00d2 | |||
| 738449a7d0 | |||
| ae80128396 | |||
| 1da5ac0bfe | |||
| f56f240d9b | |||
| 7de0ffb7f7 | |||
| 0e667c5d3d | |||
| 465c935879 |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
@@ -57,6 +57,7 @@ function ResolvedCollectionIcon({
|
||||
size={size}
|
||||
initial={collection.initial}
|
||||
className={className}
|
||||
forceColor={inputColor ? true : false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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?: (
|
||||
|
||||
@@ -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
|
||||
|
||||
+65
-14
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
@@ -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>`
|
||||
|
||||
@@ -123,7 +123,7 @@ export default function blockMenuItems(dictionary: Dictionary): MenuItem[] {
|
||||
name: "code_block",
|
||||
title: dictionary.codeBlock,
|
||||
icon: <CodeIcon />,
|
||||
shortcut: "^ ⇧ \\",
|
||||
shortcut: "^ ⇧ c",
|
||||
keywords: "script",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
{
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 |
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -670,7 +670,7 @@ class User extends ParanoidModel<
|
||||
if (attachment) {
|
||||
await DeleteAttachmentTask.schedule({
|
||||
attachmentId: attachment.id,
|
||||
teamId: model.id,
|
||||
teamId: model.teamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(/&$/, "")
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -130,7 +130,6 @@ export default class ImportNotionTask extends ImportTask {
|
||||
output.documents.push({
|
||||
id,
|
||||
title,
|
||||
emoji: icon,
|
||||
icon,
|
||||
text,
|
||||
collectionId,
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user