mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe2c0d75ef | |||
| 2e9c451979 | |||
| 3a97361654 | |||
| a8bdc14ca2 | |||
| 563b41e34a | |||
| 0621706a95 | |||
| ec1cb61807 | |||
| 5850181c29 | |||
| d29bc22676 | |||
| a9dd771598 | |||
| 35d185a971 | |||
| d6c85f0aac | |||
| b721287d19 | |||
| 57296e3139 | |||
| 4210f877b4 | |||
| 4db8682423 | |||
| 74229813d2 | |||
| 75e457feee | |||
| 36682574c6 | |||
| c636c4e5df | |||
| c23b5d1ef4 | |||
| 9f8298d012 |
@@ -189,10 +189,6 @@ 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)
|
||||
|
||||
@@ -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 || document?.template) {
|
||||
if (document?.collectionId) {
|
||||
await document.save(undefined, {
|
||||
publish: true,
|
||||
});
|
||||
@@ -688,7 +688,7 @@ export const createTemplateFromDocument = createAction({
|
||||
}
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).updateDocument
|
||||
stores.policies.abilities(activeCollectionId).update
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
@@ -735,50 +735,11 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
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");
|
||||
},
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
analyticsName: "Move document",
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
@@ -802,44 +763,6 @@ export const moveDocumentToCollection = 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",
|
||||
@@ -1074,8 +997,7 @@ export const rootDocumentActions = [
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
moveTemplateToWorkspace,
|
||||
moveDocumentToCollection,
|
||||
moveDocument,
|
||||
openRandomDocument,
|
||||
permanentlyDeleteDocument,
|
||||
permanentlyDeleteDocumentsInTrash,
|
||||
|
||||
@@ -76,7 +76,8 @@ function DocumentListItem(
|
||||
const queryIsInTitle =
|
||||
!!highlight &&
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar = !document.isArchived && !document.isTemplate;
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
|
||||
return (
|
||||
<DocumentLink
|
||||
@@ -110,6 +111,11 @@ 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")}
|
||||
@@ -119,11 +125,6 @@ function DocumentListItem(
|
||||
<Badge>{t("Draft")}</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canStar && (
|
||||
<StarPositioner>
|
||||
<StarButton document={document} />
|
||||
</StarPositioner>
|
||||
)}
|
||||
{document.isTemplate && showTemplate && (
|
||||
<Badge primary>{t("Template")}</Badge>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
@@ -100,7 +100,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
await documents.move({ documentId: id, collectionId: collection.id });
|
||||
await documents.move(id, collection.id);
|
||||
|
||||
if (!expanded) {
|
||||
onDisclosureClick();
|
||||
|
||||
@@ -52,11 +52,7 @@ function CollectionLinkChildren({
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
index: 0,
|
||||
});
|
||||
void documents.move(item.id, collection.id, undefined, 0);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
|
||||
@@ -187,11 +187,7 @@ function InnerDocumentLink(
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: node.id,
|
||||
});
|
||||
await documents.move(item.id, collection.id, node.id);
|
||||
setExpanded(true);
|
||||
},
|
||||
canDrop: (item, monitor) =>
|
||||
@@ -253,21 +249,11 @@ function InnerDocumentLink(
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: node.id,
|
||||
index: 0,
|
||||
});
|
||||
void documents.move(item.id, collection.id, node.id, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
void documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: parentId,
|
||||
index: index + 1,
|
||||
});
|
||||
void documents.move(item.id, collection.id, parentId, index + 1);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: monitor.isOver(),
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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;
|
||||
@@ -1,113 +0,0 @@
|
||||
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);
|
||||
@@ -1,82 +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 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);
|
||||
@@ -20,6 +20,7 @@ import React, { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
|
||||
import ZapierIcon from "~/components/Icons/ZapierIcon";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import { Hook, PluginManager } from "~/utils/PluginManager";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
@@ -125,7 +126,9 @@ const useSettingsConfig = () => {
|
||||
name: t("Data Attributes"),
|
||||
path: settingsPath("attributes"),
|
||||
component: DataAttributes,
|
||||
enabled: can.createDataAttribute,
|
||||
enabled:
|
||||
can.createDataAttribute &&
|
||||
FeatureFlags.isEnabled(Feature.dataAttributes),
|
||||
group: t("Workspace"),
|
||||
icon: DatabaseIcon,
|
||||
},
|
||||
@@ -149,7 +152,7 @@ const useSettingsConfig = () => {
|
||||
name: t("Templates"),
|
||||
path: settingsPath("templates"),
|
||||
component: Templates,
|
||||
enabled: can.readTemplate,
|
||||
enabled: can.update,
|
||||
group: t("Workspace"),
|
||||
icon: ShapesIcon,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import capitalize from "lodash/capitalize";
|
||||
import { observer } from "mobx-react";
|
||||
import { EditIcon, InputIcon, RestoreIcon, SearchIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
@@ -45,7 +44,6 @@ import {
|
||||
shareDocument,
|
||||
copyDocument,
|
||||
searchInDocument,
|
||||
moveTemplate,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
@@ -126,11 +124,7 @@ function DocumentMenu({
|
||||
}
|
||||
) => {
|
||||
await document.restore(options);
|
||||
toast.success(
|
||||
t("{{ documentName }} restored", {
|
||||
documentName: capitalize(document.noun),
|
||||
})
|
||||
);
|
||||
toast.success(t("Document restored"));
|
||||
},
|
||||
[t, document]
|
||||
);
|
||||
@@ -234,10 +228,7 @@ function DocumentMenu({
|
||||
{
|
||||
type: "button",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
((document.isWorkspaceTemplate || !!collection) &&
|
||||
can.restore) ||
|
||||
can.unarchive,
|
||||
visible: (!!collection && can.restore) || can.unarchive,
|
||||
onClick: (ev) => handleRestore(ev),
|
||||
icon: <RestoreIcon />,
|
||||
},
|
||||
@@ -245,10 +236,7 @@ function DocumentMenu({
|
||||
type: "submenu",
|
||||
title: t("Restore"),
|
||||
visible:
|
||||
!document.isWorkspaceTemplate &&
|
||||
!collection &&
|
||||
!!can.restore &&
|
||||
restoreItems.length !== 0,
|
||||
!collection && !!can.restore && restoreItems.length !== 0,
|
||||
style: {
|
||||
left: -170,
|
||||
position: "relative",
|
||||
@@ -302,7 +290,6 @@ 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,16 +28,7 @@ function NewTemplateMenu() {
|
||||
});
|
||||
}, [collections]);
|
||||
|
||||
const workspaceItem: MenuItem | null = can.createTemplate
|
||||
? {
|
||||
type: "route",
|
||||
to: newTemplatePath(),
|
||||
title: t("Save in workspace"),
|
||||
icon: <TeamLogo model={team} />,
|
||||
}
|
||||
: null;
|
||||
|
||||
const collectionItems = React.useMemo(
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
const can = policies.abilities(collection.id);
|
||||
@@ -56,28 +47,7 @@ function NewTemplateMenu() {
|
||||
[collections.orderedData, policies]
|
||||
);
|
||||
|
||||
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) {
|
||||
if (!can.createDocument || items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -91,6 +61,7 @@ function NewTemplateMenu() {
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu aria-label={t("New template")} {...menu}>
|
||||
<Header>{t("Choose a collection")}</Header>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
|
||||
+33
-49
@@ -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 Template from "~/components/ContextMenu/Template";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
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,56 +25,36 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
const user = useCurrentUser();
|
||||
const { documents } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const templates = documents.templates;
|
||||
|
||||
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) {
|
||||
if (!templates.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}>
|
||||
@@ -85,7 +65,11 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) {
|
||||
)}
|
||||
</MenuButton>
|
||||
<ContextMenu {...menu} aria-label={t("Templates")}>
|
||||
<Template {...menu} items={items} />
|
||||
{templatesInCollection.map(renderTemplate)}
|
||||
{otherTemplates.length && templatesInCollection.length ? (
|
||||
<Separator />
|
||||
) : undefined}
|
||||
{otherTemplates.map(renderTemplate)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
|
||||
+3
-16
@@ -390,11 +390,6 @@ 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");
|
||||
}
|
||||
@@ -546,13 +541,7 @@ export default class Document extends ParanoidModel {
|
||||
};
|
||||
|
||||
@action
|
||||
templatize = ({
|
||||
collectionId,
|
||||
publish,
|
||||
}: {
|
||||
collectionId: string | null;
|
||||
publish: boolean;
|
||||
}) => this.store.templatize({ id: this.id, collectionId, publish });
|
||||
templatize = () => this.store.templatize(this.id);
|
||||
|
||||
@action
|
||||
save = async (
|
||||
@@ -579,10 +568,8 @@ export default class Document extends ParanoidModel {
|
||||
}
|
||||
};
|
||||
|
||||
move = (options: {
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string;
|
||||
}) => this.store.move({ documentId: this.id, ...options });
|
||||
move = (collectionId: string, parentDocumentId?: string | undefined) =>
|
||||
this.store.move(this.id, collectionId, parentDocumentId);
|
||||
|
||||
duplicate = (options?: {
|
||||
title?: string;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function Contents({ headings }: Props) {
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let activeId = headings[0]?.id;
|
||||
let activeId = headings.at(0)?.id;
|
||||
|
||||
for (let key = 0; key < headings.length; key++) {
|
||||
const heading = headings[key];
|
||||
|
||||
@@ -13,6 +13,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import Logger from "~/utils/Logger";
|
||||
import {
|
||||
NotFoundError,
|
||||
@@ -88,14 +89,16 @@ function DataLoader({ match, children }: Props) {
|
||||
match.path === matchDocumentEdit || match.path.startsWith(settingsPath());
|
||||
const isEditing = isEditRoute || !user?.separateEditMode;
|
||||
const can = usePolicy(document);
|
||||
const canTeam = usePolicy(team);
|
||||
const location = useLocation<LocationState>();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!dataAttributes.isLoaded && canTeam.listDataAttribute) {
|
||||
if (
|
||||
!dataAttributes.isLoaded &&
|
||||
FeatureFlags.isEnabled(Feature.dataAttributes)
|
||||
) {
|
||||
void dataAttributes.fetchAll();
|
||||
}
|
||||
}, [dataAttributes, canTeam]);
|
||||
}, [dataAttributes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchDocument() {
|
||||
@@ -192,7 +195,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 && !document.template) {
|
||||
if (!can.update && isEditRoute) {
|
||||
history.push(document.url);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
import { documentPath, documentInsightsPath } from "~/utils/routeHelpers";
|
||||
import { Properties, PropertiesRef } from "./Properties";
|
||||
|
||||
@@ -38,7 +39,6 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].userId;
|
||||
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
|
||||
const can = usePolicy(document);
|
||||
const canTeam = usePolicy(team);
|
||||
const propertiesRef = React.useRef<PropertiesRef>(null);
|
||||
|
||||
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
||||
@@ -47,7 +47,8 @@ function TitleDocumentMeta({ to, document, revision, ...rest }: Props) {
|
||||
const commentsCount = comments.unresolvedCommentsInDocumentCount(document.id);
|
||||
|
||||
const dataAttributesAvailable =
|
||||
canTeam.listDataAttribute && dataAttributes.orderedData.length > 0;
|
||||
FeatureFlags.isEnabled(Feature.dataAttributes) &&
|
||||
dataAttributes.orderedData.length > 0;
|
||||
const missingDataAttributes =
|
||||
!document.dataAttributes ||
|
||||
document.dataAttributes?.length < dataAttributes.orderedData.length;
|
||||
|
||||
@@ -116,9 +116,8 @@ function DocumentHeader({
|
||||
activeDocumentId: document?.id,
|
||||
});
|
||||
|
||||
const can = usePolicy(document);
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const isTemplateEditable = can.update && isTemplate;
|
||||
const can = usePolicy(document);
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const isShare = !!shareId;
|
||||
const showContents =
|
||||
@@ -277,7 +276,7 @@ function DocumentHeader({
|
||||
<ShareButton document={document} />
|
||||
</Action>
|
||||
)}
|
||||
{(isEditing || isTemplateEditable) && (
|
||||
{(isEditing || isTemplate) && (
|
||||
<Action>
|
||||
<Tooltip
|
||||
content={t("Save")}
|
||||
@@ -352,9 +351,7 @@ function DocumentHeader({
|
||||
hideOnActionDisabled
|
||||
hideIcon
|
||||
>
|
||||
{document.collectionId || document.isWorkspaceTemplate
|
||||
? t("Publish")
|
||||
: `${t("Publish")}…`}
|
||||
{document.collectionId ? t("Publish") : `${t("Publish")}…`}
|
||||
</Button>
|
||||
</Action>
|
||||
)}
|
||||
|
||||
@@ -20,10 +20,10 @@ import InputSelect from "~/components/InputSelect";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { DataAttributesHelper } from "~/utils/DataAttributesHelper";
|
||||
import { Feature, FeatureFlags } from "~/utils/FeatureFlags";
|
||||
|
||||
const PropertyHeight = 30;
|
||||
|
||||
@@ -38,9 +38,7 @@ export type PropertiesRef = {
|
||||
export const Properties = observer(
|
||||
React.forwardRef(function Properties_({ document }: Props, ref) {
|
||||
const { dataAttributes } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(document);
|
||||
const canTeam = usePolicy(team);
|
||||
const [draftAttribute, setDraftAttribute] =
|
||||
React.useState<DocumentDataAttribute | null>(null);
|
||||
|
||||
@@ -78,7 +76,7 @@ export const Properties = observer(
|
||||
}
|
||||
};
|
||||
|
||||
if (!canTeam.listDataAttribute) {
|
||||
if (!FeatureFlags.isEnabled(Feature.dataAttributes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,7 @@ import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
collectionPath,
|
||||
documentPath,
|
||||
settingsPath,
|
||||
} from "~/utils/routeHelpers";
|
||||
import { collectionPath, documentPath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -25,8 +21,7 @@ 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 && !document.template;
|
||||
const canArchive = !document.isDraft && !document.isArchived;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -55,12 +50,8 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// otherwise, redirect to the collection home
|
||||
history.push(collectionPath(collection?.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,10 +48,7 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await documents.move({
|
||||
documentId: item.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
await documents.move(item.id, collection.id);
|
||||
toast.message(t("Document moved"));
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
|
||||
@@ -457,15 +457,7 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
templatize = async ({
|
||||
id,
|
||||
collectionId,
|
||||
publish,
|
||||
}: {
|
||||
id: string;
|
||||
collectionId: string | null;
|
||||
publish: boolean;
|
||||
}): Promise<Document | null | undefined> => {
|
||||
templatize = async (id: string): Promise<Document | null | undefined> => {
|
||||
const doc: Document | null | undefined = this.data.get(id);
|
||||
invariant(doc, "Document should exist");
|
||||
|
||||
@@ -475,8 +467,6 @@ 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);
|
||||
@@ -556,17 +546,12 @@ export default class DocumentsStore extends Store<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
move = async ({
|
||||
documentId,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
index,
|
||||
}: {
|
||||
documentId: string;
|
||||
collectionId?: string | null;
|
||||
parentDocumentId?: string | null;
|
||||
index?: number | null;
|
||||
}) => {
|
||||
move = async (
|
||||
documentId: string,
|
||||
collectionId: string,
|
||||
parentDocumentId?: string | null,
|
||||
index?: number | null
|
||||
) => {
|
||||
this.movingDocumentId = documentId;
|
||||
|
||||
try {
|
||||
|
||||
@@ -4,11 +4,14 @@ import Storage from "@shared/utils/Storage";
|
||||
export enum Feature {
|
||||
/** New collection permissions UI */
|
||||
newCollectionSharing = "newCollectionSharing",
|
||||
/** Document data attributes */
|
||||
dataAttributes = "dataAttributes",
|
||||
}
|
||||
|
||||
/** Default values for feature flags */
|
||||
const FeatureDefaults: Record<Feature, boolean> = {
|
||||
[Feature.newCollectionSharing]: true,
|
||||
[Feature.dataAttributes]: false,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,10 +81,8 @@ export function updateDocumentPath(oldUrl: string, document: Document): string {
|
||||
);
|
||||
}
|
||||
|
||||
export function newTemplatePath(collectionId?: string) {
|
||||
return collectionId
|
||||
? settingsPath("templates") + `/new?collectionId=${collectionId}`
|
||||
: `${settingsPath("templates")}/new`;
|
||||
export function newTemplatePath(collectionId: string) {
|
||||
return settingsPath("templates") + `/new?collectionId=${collectionId}`;
|
||||
}
|
||||
|
||||
export function newDocumentPath(
|
||||
|
||||
+10
-9
@@ -8,10 +8,10 @@ Outline's frontend is a React application compiled with [Vite](https://vitejs.de
|
||||
|
||||
```
|
||||
app
|
||||
├── actions - Reusable actions such as navigating, opening, creating entities
|
||||
├── components - React components reusable across scenes
|
||||
├── editor - React components specific to the editor
|
||||
├── embeds - Embed definitions that represent rich interactive embeds in the editor
|
||||
├── hooks - Reusable React hooks
|
||||
├── actions - Reusable actions such as navigating, opening, creating entities
|
||||
├── 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,14 +30,15 @@ Interested in more documentation on the API routes? Check out the [API documenta
|
||||
|
||||
```
|
||||
server
|
||||
├── routes - All API routes are contained within here
|
||||
│ ├── api - API routes
|
||||
│ └── auth - Authentication routes
|
||||
├── commands - Complex commands that perform actions across multiple models
|
||||
├── 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
|
||||
├── config - Database configuration
|
||||
├── emails - Transactional email templates
|
||||
│ └── templates - Classes that define each possible email template
|
||||
├── middlewares - Shared Koa middlewares
|
||||
├── middlewares - Koa middlewares
|
||||
├── migrations - Database migrations
|
||||
├── models - Sequelize models
|
||||
├── onboarding - Markdown templates for onboarding documents
|
||||
@@ -59,10 +60,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
|
||||
├── utils - Shared utility methods
|
||||
└── constants - Shared constants
|
||||
```
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# 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
|
||||
+7
-5
@@ -47,11 +47,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/plugin-transform-class-properties": "^7.24.7",
|
||||
@@ -364,7 +364,9 @@
|
||||
"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 await res.json();
|
||||
return res.json();
|
||||
} catch (err) {
|
||||
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
|
||||
return;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,6 +1,5 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Optional } from "utility-types";
|
||||
import { DocumentDataAttribute } from "@shared/models/types";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { TextHelper } from "@server/models/helpers/TextHelper";
|
||||
@@ -30,7 +29,6 @@ type Props = Optional<
|
||||
state?: Buffer;
|
||||
publish?: boolean;
|
||||
templateDocument?: Document | null;
|
||||
dataAttributes?: Omit<DocumentDataAttribute, "updatedAt">[] | null;
|
||||
user: User;
|
||||
ip?: string;
|
||||
transaction?: Transaction;
|
||||
@@ -47,7 +45,6 @@ export default async function documentCreator({
|
||||
publish,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
dataAttributes,
|
||||
content,
|
||||
template,
|
||||
templateDocument,
|
||||
@@ -89,14 +86,6 @@ export default async function documentCreator({
|
||||
id,
|
||||
urlId,
|
||||
parentDocumentId,
|
||||
dataAttributes: dataAttributes?.map(
|
||||
({ dataAttributeId, value }) =>
|
||||
({
|
||||
dataAttributeId,
|
||||
value,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as DocumentDataAttribute)
|
||||
),
|
||||
editorVersion,
|
||||
collectionId,
|
||||
teamId: user.teamId,
|
||||
@@ -159,11 +148,14 @@ export default async function documentCreator({
|
||||
);
|
||||
|
||||
if (publish) {
|
||||
if (!collectionId && !template) {
|
||||
if (!collectionId) {
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import invariant from "invariant";
|
||||
import { Transaction } from "sequelize";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import {
|
||||
User,
|
||||
@@ -57,6 +58,10 @@ 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;
|
||||
|
||||
@@ -10,7 +10,7 @@ type Props = {
|
||||
/** The existing document */
|
||||
document: Document;
|
||||
/** Data attributes to apply to the document */
|
||||
dataAttributes?: Omit<DocumentDataAttribute, "updatedAt">[] | null;
|
||||
dataAttributes?: Omit<DocumentDataAttribute, "updatedAt">[];
|
||||
/** The new title */
|
||||
title?: string;
|
||||
/** The document icon */
|
||||
@@ -71,25 +71,25 @@ export default async function documentUpdater({
|
||||
const cId = collectionId || document.collectionId;
|
||||
|
||||
if (dataAttributes !== undefined) {
|
||||
document.dataAttributes = dataAttributes
|
||||
? uniqBy(
|
||||
dataAttributes.map(({ dataAttributeId, value }) => {
|
||||
const existing = document.dataAttributes?.find(
|
||||
(da) => da.dataAttributeId === dataAttributeId
|
||||
);
|
||||
if (existing?.value === value) {
|
||||
return existing as DocumentDataAttribute;
|
||||
}
|
||||
// TODO: Validate schema
|
||||
|
||||
return {
|
||||
dataAttributeId,
|
||||
value,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as DocumentDataAttribute;
|
||||
}),
|
||||
"dataAttributeId"
|
||||
)
|
||||
: null;
|
||||
document.dataAttributes = uniqBy(
|
||||
dataAttributes.map(({ dataAttributeId, value }) => {
|
||||
const existing = document.dataAttributes.find(
|
||||
(da) => da.dataAttributeId === dataAttributeId
|
||||
);
|
||||
if (existing?.value === value) {
|
||||
return existing as DocumentDataAttribute;
|
||||
}
|
||||
|
||||
return {
|
||||
dataAttributeId,
|
||||
value,
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as DocumentDataAttribute;
|
||||
}),
|
||||
"dataAttributeId"
|
||||
);
|
||||
}
|
||||
|
||||
if (title !== undefined) {
|
||||
@@ -133,7 +133,7 @@ export default async function documentUpdater({
|
||||
ip,
|
||||
};
|
||||
|
||||
if (publish && (document.template || cId)) {
|
||||
if (publish && cId) {
|
||||
if (!document.collectionId) {
|
||||
document.collectionId = cId;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Event, Star, User } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
|
||||
type Props = {
|
||||
/** The user destroying the star */
|
||||
@@ -23,21 +24,31 @@ export default async function starDestroyer({
|
||||
user,
|
||||
star,
|
||||
ip,
|
||||
transaction,
|
||||
transaction: t,
|
||||
}: Props): Promise<Star> {
|
||||
await star.destroy({ transaction });
|
||||
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 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,8 +10,6 @@ type Props = {
|
||||
index: string;
|
||||
/** The IP address of the user creating the star */
|
||||
ip: string;
|
||||
/** Optional existing transaction */
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -26,22 +24,30 @@ export default async function starUpdater({
|
||||
star,
|
||||
index,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Star> {
|
||||
star.index = index;
|
||||
await star.save({ transaction });
|
||||
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;
|
||||
}
|
||||
|
||||
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,13 +348,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -36,7 +36,7 @@ module.exports = {
|
||||
allowNull: true,
|
||||
},
|
||||
dataType: {
|
||||
type: Sequelize.ENUM("string", "number", "boolean", "list"),
|
||||
type: Sequelize.ENUM("string", "integer", "boolean", "list"),
|
||||
allowNull: false,
|
||||
},
|
||||
options: {
|
||||
|
||||
@@ -282,9 +282,9 @@ class Document extends ParanoidModel<
|
||||
@Column
|
||||
color: string | null;
|
||||
|
||||
/** Attributes associated with the document. */
|
||||
// TODO
|
||||
@Column(DataType.JSONB)
|
||||
dataAttributes: DocumentDataAttribute[] | null;
|
||||
dataAttributes: DocumentDataAttribute[];
|
||||
|
||||
/**
|
||||
* The content of the document as Markdown.
|
||||
@@ -377,7 +377,7 @@ class Document extends ParanoidModel<
|
||||
model: Document,
|
||||
{ transaction }: SaveOptions<Document>
|
||||
) {
|
||||
if (model.changed("dataAttributes") && model.dataAttributes) {
|
||||
if (model.changed("dataAttributes")) {
|
||||
const dataAttributeIds = model.dataAttributes.map(
|
||||
(d) => d.dataAttributeId
|
||||
);
|
||||
@@ -776,13 +776,6 @@ 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.
|
||||
*
|
||||
@@ -878,7 +871,7 @@ class Document extends ParanoidModel<
|
||||
|
||||
publish = async (
|
||||
user: User,
|
||||
collectionId: string | null | undefined,
|
||||
collectionId: string,
|
||||
options: SaveOptions
|
||||
): Promise<this> => {
|
||||
const { transaction } = options;
|
||||
@@ -893,7 +886,7 @@ class Document extends ParanoidModel<
|
||||
this.collectionId = collectionId;
|
||||
}
|
||||
|
||||
if (!this.template && this.collectionId) {
|
||||
if (!this.template) {
|
||||
const collection = await Collection.findByPk(this.collectionId, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
|
||||
@@ -12,19 +12,11 @@ describe("DocumentHelper", () => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -34,7 +26,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
|
||||
|
||||
@@ -83,10 +83,6 @@ export class DocumentHelper {
|
||||
let json;
|
||||
|
||||
if ("content" in document && document.content) {
|
||||
// Optimized path for documents with content available and no transformation required.
|
||||
if (!options?.removeMarks && !options?.signedUrls) {
|
||||
return document.content;
|
||||
}
|
||||
doc = Node.fromJSON(schema, document.content);
|
||||
} else if ("state" in document && document.state) {
|
||||
const ydoc = new Y.Doc();
|
||||
|
||||
@@ -486,25 +486,6 @@ 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,12 +555,7 @@ export default class SearchHelper {
|
||||
}
|
||||
|
||||
return (
|
||||
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()}*`
|
||||
)
|
||||
queryParser()(quotedSearch ? limitedQuery : `${limitedQuery}*`)
|
||||
// Remove any trailing join characters
|
||||
.replace(/&$/, "")
|
||||
);
|
||||
|
||||
@@ -1,28 +1,18 @@
|
||||
import env from "@server/env";
|
||||
import { User, Team, DataAttribute } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import { and, isTeamAdmin, isTeamModel, isTeamMutable } from "./utils";
|
||||
|
||||
const isEnabled = !env.isCloudHosted;
|
||||
|
||||
allow(User, "createDataAttribute", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
isTeamAdmin(actor, team),
|
||||
isTeamMutable(actor),
|
||||
!actor.isSuspended,
|
||||
!env.isCloudHosted
|
||||
!actor.isSuspended
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "listDataAttribute", Team, (actor, team) =>
|
||||
and(isTeamModel(actor, team), isEnabled)
|
||||
);
|
||||
allow(User, "listDataAttribute", Team, isTeamModel);
|
||||
|
||||
allow(User, "read", DataAttribute, (actor, team) =>
|
||||
and(isTeamModel(actor, team), isEnabled)
|
||||
);
|
||||
allow(User, "read", DataAttribute, isTeamModel);
|
||||
|
||||
allow(User, ["update", "delete"], DataAttribute, (actor, team) =>
|
||||
and(isTeamAdmin(actor, team), isEnabled)
|
||||
);
|
||||
allow(User, ["update", "delete"], DataAttribute, isTeamAdmin);
|
||||
|
||||
@@ -29,10 +29,6 @@ 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)
|
||||
)
|
||||
)
|
||||
@@ -102,14 +98,7 @@ allow(User, "update", Document, (actor, document) =>
|
||||
]),
|
||||
or(
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
and(!!document?.isDraft && actor.id === document?.createdById),
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
or(
|
||||
actor.id === document?.createdById,
|
||||
can(actor, "updateTemplate", actor.team)
|
||||
)
|
||||
)
|
||||
and(!!document?.isDraft && actor.id === document?.createdById)
|
||||
)
|
||||
)
|
||||
)
|
||||
@@ -129,14 +118,7 @@ allow(User, ["manageUsers", "duplicate"], Document, (actor, document) =>
|
||||
or(
|
||||
includesMembership(document, [DocumentPermission.Admin]),
|
||||
can(actor, "updateDocument", document?.collection),
|
||||
!!document?.isDraft && actor.id === document?.createdById,
|
||||
and(
|
||||
!!document?.isWorkspaceTemplate,
|
||||
or(
|
||||
actor.id === document?.createdById,
|
||||
can(actor, "updateTemplate", actor.team)
|
||||
)
|
||||
)
|
||||
!!document?.isDraft && actor.id === document?.createdById
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -146,14 +128,7 @@ 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?.isWorkspaceTemplate,
|
||||
or(
|
||||
actor.id === document?.createdById,
|
||||
can(actor, "updateTemplate", actor.team)
|
||||
)
|
||||
)
|
||||
and(!!document?.isDraft && actor.id === document?.createdById)
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -191,7 +166,7 @@ allow(User, "delete", Document, (actor, document) =>
|
||||
or(
|
||||
can(actor, "unarchive", document),
|
||||
can(actor, "update", document),
|
||||
and(!document?.isWorkspaceTemplate, !document?.collection)
|
||||
!document?.collection
|
||||
)
|
||||
)
|
||||
);
|
||||
@@ -208,10 +183,6 @@ 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
|
||||
)
|
||||
)
|
||||
@@ -265,14 +236,6 @@ 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.createTemplate).toEqual(true);
|
||||
expect(response.createDocument).toEqual(true);
|
||||
expect(response.inviteUser).toEqual(true);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { UserRole } from "@shared/types";
|
||||
import { buildUser, buildTeam, buildAdmin } from "@server/test/factories";
|
||||
import { setSelfHosted } from "@server/test/support";
|
||||
import { serialize } from "./index";
|
||||
|
||||
describe("policies/team", () => {
|
||||
describe.skip("policies/team", () => {
|
||||
it("should allow reading only", async () => {
|
||||
setSelfHosted();
|
||||
|
||||
@@ -16,7 +15,7 @@ describe("policies/team", () => {
|
||||
expect(abilities.createTeam).toEqual(false);
|
||||
expect(abilities.createAttachment).toEqual(true);
|
||||
expect(abilities.createCollection).toEqual(true);
|
||||
expect(abilities.createTemplate).toEqual(true);
|
||||
expect(abilities.createDocument).toEqual(true);
|
||||
expect(abilities.createGroup).toEqual(false);
|
||||
expect(abilities.createIntegration).toEqual(false);
|
||||
});
|
||||
@@ -33,7 +32,7 @@ describe("policies/team", () => {
|
||||
expect(abilities.createTeam).toEqual(false);
|
||||
expect(abilities.createAttachment).toEqual(true);
|
||||
expect(abilities.createCollection).toEqual(true);
|
||||
expect(abilities.createTemplate).toEqual(true);
|
||||
expect(abilities.createDocument).toEqual(true);
|
||||
expect(abilities.createGroup).toEqual(true);
|
||||
expect(abilities.createIntegration).toEqual(true);
|
||||
});
|
||||
@@ -48,71 +47,8 @@ describe("policies/team", () => {
|
||||
expect(abilities.createTeam).toEqual(true);
|
||||
expect(abilities.createAttachment).toEqual(true);
|
||||
expect(abilities.createCollection).toEqual(true);
|
||||
expect(abilities.createTemplate).toEqual(true);
|
||||
expect(abilities.createDocument).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, 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.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);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+1
-27
@@ -1,13 +1,6 @@
|
||||
import { Team, User } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
import {
|
||||
and,
|
||||
isCloudHosted,
|
||||
isTeamAdmin,
|
||||
isTeamModel,
|
||||
isTeamMutable,
|
||||
or,
|
||||
} from "./utils";
|
||||
import { and, isCloudHosted, isTeamAdmin, isTeamModel, or } from "./utils";
|
||||
|
||||
allow(User, "read", Team, isTeamModel);
|
||||
|
||||
@@ -39,22 +32,3 @@ allow(User, ["delete", "audit"], Team, (actor, team) =>
|
||||
isTeamAdmin(actor, team)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, ["createTemplate", "readTemplate"], Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
!actor.isGuest,
|
||||
!actor.isViewer,
|
||||
isTeamModel(actor, team),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
allow(User, "updateTemplate", Team, (actor, team) =>
|
||||
and(
|
||||
//
|
||||
actor.isAdmin,
|
||||
isTeamModel(actor, team),
|
||||
isTeamMutable(actor)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -359,28 +358,20 @@ 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: sharedDefaults,
|
||||
defaults: {
|
||||
...options,
|
||||
id: item.id,
|
||||
description: truncatedDescription,
|
||||
createdById: fileOperation.userId,
|
||||
permission: CollectionPermission.ReadWrite,
|
||||
importId: fileOperation.id,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
@@ -394,9 +385,21 @@ export default abstract class ImportTask extends BaseTask<Props> {
|
||||
const name = `${item.name} (Imported)`;
|
||||
collection = await Collection.create(
|
||||
{
|
||||
...sharedDefaults,
|
||||
name,
|
||||
...options,
|
||||
id: item.id,
|
||||
description: truncatedDescription,
|
||||
color: item.color,
|
||||
icon: item.icon,
|
||||
sort: item.sort,
|
||||
teamId: fileOperation.teamId,
|
||||
createdById: fileOperation.userId,
|
||||
name,
|
||||
permission:
|
||||
item.permission ??
|
||||
fileOperation.options?.permission !== undefined
|
||||
? fileOperation.options?.permission
|
||||
: CollectionPermission.ReadWrite,
|
||||
importId: fileOperation.id,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
buildShare,
|
||||
buildCollection,
|
||||
buildUser,
|
||||
buildDataAttribute,
|
||||
buildDocument,
|
||||
buildDraftDocument,
|
||||
buildViewer,
|
||||
@@ -1930,140 +1929,6 @@ 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", () => {
|
||||
@@ -2420,6 +2285,23 @@ 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({
|
||||
@@ -2507,56 +2389,6 @@ 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);
|
||||
@@ -3008,35 +2840,6 @@ describe("#documents.create", () => {
|
||||
expect(body.policies[0].abilities.update).toEqual(true);
|
||||
});
|
||||
|
||||
it("should create as a new document with data attributes", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const dataAttribute = await buildDataAttribute({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
const res = await server.post("/api/documents.create", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
collectionId: collection.id,
|
||||
dataAttributes: [
|
||||
{
|
||||
dataAttributeId: dataAttribute.id,
|
||||
value: "123",
|
||||
},
|
||||
],
|
||||
title: "new document",
|
||||
text: "hello",
|
||||
publish: true,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
});
|
||||
|
||||
it("should create a draft document not belonging to any collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
@@ -3055,7 +2858,7 @@ describe("#documents.create", () => {
|
||||
expect(body.data.collectionId).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow creating a draft template without a collection", async () => {
|
||||
it("should not allow creating a template with a collection", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const res = await server.post("/api/documents.create", {
|
||||
@@ -3068,10 +2871,10 @@ describe("#documents.create", () => {
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.template).toBe(true);
|
||||
expect(body.data.publishedAt).toBeNull();
|
||||
expect(body.data.collectionId).toBeNull();
|
||||
expect(res.status).toEqual(400);
|
||||
expect(body.message).toBe(
|
||||
"collectionId is required to create a template document"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow publishing without specifying the collection", async () => {
|
||||
@@ -3291,39 +3094,6 @@ 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 });
|
||||
|
||||
@@ -48,7 +48,7 @@ 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, can, cannot } from "@server/policies";
|
||||
import { authorize, cannot } from "@server/policies";
|
||||
import {
|
||||
presentCollection,
|
||||
presentDocument,
|
||||
@@ -129,15 +129,7 @@ router.post(
|
||||
} // otherwise, filter by all collections the user has access to
|
||||
} else {
|
||||
const collectionIds = await user.collectionIds();
|
||||
where = {
|
||||
...where,
|
||||
collectionId:
|
||||
template && can(user, "readTemplate", user.team)
|
||||
? {
|
||||
[Op.or]: [{ [Op.in]: collectionIds }, { [Op.is]: null }],
|
||||
}
|
||||
: collectionIds,
|
||||
};
|
||||
where = { ...where, collectionId: collectionIds };
|
||||
}
|
||||
|
||||
if (parentDocumentId) {
|
||||
@@ -923,7 +915,7 @@ router.post(
|
||||
validate(T.DocumentsTemplatizeSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.DocumentsTemplatizeReq>) => {
|
||||
const { id, collectionId, publish } = ctx.input.body;
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
@@ -934,21 +926,12 @@ 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,
|
||||
teamId: user.teamId,
|
||||
publishedAt: publish ? new Date() : null,
|
||||
collectionId: original.collectionId,
|
||||
teamId: original.teamId,
|
||||
publishedAt: new Date(),
|
||||
lastModifiedById: user.id,
|
||||
createdById: user.id,
|
||||
template: true,
|
||||
@@ -1024,7 +1007,7 @@ router.post(
|
||||
authorize(user, "publish", document);
|
||||
}
|
||||
|
||||
if (!document.collectionId && !document.isWorkspaceTemplate) {
|
||||
if (!document.collectionId) {
|
||||
assertPresent(
|
||||
collectionId,
|
||||
"collectionId is required to publish a draft without collection"
|
||||
@@ -1043,8 +1026,6 @@ router.post(
|
||||
}
|
||||
);
|
||||
authorize(user, "createChildDocument", parentDocument, { collection });
|
||||
} else if (document.isWorkspaceTemplate) {
|
||||
authorize(user, "createTemplate", user.team);
|
||||
} else {
|
||||
authorize(user, "createDocument", collection);
|
||||
}
|
||||
@@ -1095,8 +1076,6 @@ router.post(
|
||||
|
||||
if (collection) {
|
||||
authorize(user, "updateDocument", collection);
|
||||
} else if (document.isWorkspaceTemplate) {
|
||||
authorize(user, "createTemplate", user.team);
|
||||
}
|
||||
|
||||
if (parentDocumentId) {
|
||||
@@ -1149,16 +1128,10 @@ router.post(
|
||||
});
|
||||
authorize(user, "move", document);
|
||||
|
||||
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");
|
||||
}
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(collectionId, { transaction });
|
||||
authorize(user, "updateDocument", collection);
|
||||
|
||||
if (parentDocumentId) {
|
||||
const parent = await Document.findByPk(parentDocumentId, {
|
||||
@@ -1175,7 +1148,7 @@ router.post(
|
||||
const { documents, collections, collectionChanged } = await documentMover({
|
||||
user,
|
||||
document,
|
||||
collectionId: collectionId ?? null,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
index,
|
||||
ip: ctx.request.ip,
|
||||
@@ -1409,7 +1382,6 @@ router.post(
|
||||
publish,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
dataAttributes,
|
||||
fullWidth,
|
||||
templateId,
|
||||
template,
|
||||
@@ -1455,8 +1427,6 @@ router.post(
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "createDocument", collection);
|
||||
} else if (!!template && !collectionId) {
|
||||
authorize(user, "createTemplate", user.team);
|
||||
}
|
||||
|
||||
let templateDocument: Document | null | undefined;
|
||||
@@ -1478,7 +1448,6 @@ router.post(
|
||||
publish,
|
||||
collectionId: collection?.id,
|
||||
parentDocumentId,
|
||||
dataAttributes,
|
||||
templateDocument,
|
||||
template,
|
||||
fullWidth,
|
||||
|
||||
@@ -195,12 +195,7 @@ export const DocumentsDuplicateSchema = BaseSchema.extend({
|
||||
export type DocumentsDuplicateReq = z.infer<typeof DocumentsDuplicateSchema>;
|
||||
|
||||
export const DocumentsTemplatizeSchema = BaseSchema.extend({
|
||||
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(),
|
||||
}),
|
||||
body: BaseIdSchema,
|
||||
});
|
||||
|
||||
export type DocumentsTemplatizeReq = z.infer<typeof DocumentsTemplatizeSchema>;
|
||||
@@ -257,7 +252,7 @@ export const DocumentsUpdateSchema = BaseSchema.extend({
|
||||
value: z.string().or(z.boolean()).or(z.number()),
|
||||
})
|
||||
)
|
||||
.nullish(),
|
||||
.optional(),
|
||||
}),
|
||||
}).refine((req) => !(req.body.append && !req.body.text), {
|
||||
message: "text is required while appending",
|
||||
@@ -268,7 +263,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().nullish(),
|
||||
collectionId: z.string().uuid(),
|
||||
|
||||
/** Parent Id, in case if the doc is moved to a new parent */
|
||||
parentDocumentId: z.string().uuid().nullish(),
|
||||
@@ -354,16 +349,6 @@ export const DocumentsCreateSchema = BaseSchema.extend({
|
||||
/** A template to create the document from */
|
||||
templateId: z.string().uuid().optional(),
|
||||
|
||||
/** Data attributes to be included on the document */
|
||||
dataAttributes: z
|
||||
.array(
|
||||
z.object({
|
||||
dataAttributeId: z.string(),
|
||||
value: z.string().or(z.boolean()).or(z.number()),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
|
||||
/** Optionally set the created date in the past */
|
||||
createdAt: z.coerce
|
||||
.date()
|
||||
@@ -378,13 +363,21 @@ export const DocumentsCreateSchema = BaseSchema.extend({
|
||||
/** Whether this should be considered a template */
|
||||
template: z.boolean().optional(),
|
||||
}),
|
||||
}).refine(
|
||||
(req) =>
|
||||
!(req.body.publish && !req.body.parentDocumentId && !req.body.collectionId),
|
||||
{
|
||||
message: "collectionId or parentDocumentId is required to publish",
|
||||
}
|
||||
);
|
||||
})
|
||||
.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",
|
||||
}
|
||||
);
|
||||
|
||||
export type DocumentsCreateReq = z.infer<typeof DocumentsCreateSchema>;
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -100,38 +99,26 @@ 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,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
// reload to get default scope
|
||||
const group = await Group.findByPk(g.id, {
|
||||
transaction,
|
||||
rejectOnEmpty: true,
|
||||
const g = await Group.create({
|
||||
name,
|
||||
teamId: user.teamId,
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "groups.create",
|
||||
modelId: group.id,
|
||||
data: {
|
||||
name: group.name,
|
||||
},
|
||||
// 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,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentGroup(group),
|
||||
@@ -144,30 +131,24 @@ 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, { transaction });
|
||||
const group = await Group.findByPk(id);
|
||||
authorize(user, "update", group);
|
||||
|
||||
group.name = name;
|
||||
|
||||
if (group.changed()) {
|
||||
await group.save({ transaction });
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "groups.update",
|
||||
modelId: group.id,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
await group.save();
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "groups.update",
|
||||
modelId: group.id,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
@@ -181,27 +162,21 @@ 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, { transaction });
|
||||
const group = await Group.findByPk(id);
|
||||
authorize(user, "delete", group);
|
||||
|
||||
await group.destroy({ transaction });
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "groups.delete",
|
||||
modelId: group.id,
|
||||
data: {
|
||||
name: group.name,
|
||||
},
|
||||
await group.destroy();
|
||||
await Event.createFromContext(ctx, {
|
||||
name: "groups.delete",
|
||||
modelId: group.id,
|
||||
data: {
|
||||
name: group.name,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
@@ -263,16 +238,14 @@ 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, { transaction });
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(actor, "read", user);
|
||||
|
||||
let group = await Group.findByPk(id, { transaction });
|
||||
let group = await Group.findByPk(id);
|
||||
authorize(actor, "update", group);
|
||||
|
||||
let groupUser = await GroupUser.findOne({
|
||||
@@ -280,7 +253,6 @@ router.post(
|
||||
groupId: id,
|
||||
userId,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (!groupUser) {
|
||||
@@ -288,7 +260,6 @@ router.post(
|
||||
through: {
|
||||
createdById: actor.id,
|
||||
},
|
||||
transaction,
|
||||
});
|
||||
// reload to get default scope
|
||||
groupUser = await GroupUser.findOne({
|
||||
@@ -297,24 +268,19 @@ router.post(
|
||||
userId,
|
||||
},
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
// reload to get default scope
|
||||
group = await Group.findByPk(id, { transaction, rejectOnEmpty: true });
|
||||
group = await Group.findByPk(id, { 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 = {
|
||||
@@ -331,34 +297,28 @@ 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, { transaction });
|
||||
let group = await Group.findByPk(id);
|
||||
authorize(actor, "update", group);
|
||||
|
||||
const user = await User.findByPk(userId, { transaction });
|
||||
const user = await User.findByPk(userId);
|
||||
authorize(actor, "read", user);
|
||||
|
||||
await group.$remove("user", user, { transaction });
|
||||
await Event.createFromContext(
|
||||
ctx,
|
||||
{
|
||||
name: "groups.remove_user",
|
||||
userId,
|
||||
modelId: group.id,
|
||||
data: {
|
||||
name: user.name,
|
||||
},
|
||||
await group.$remove("user", user);
|
||||
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, { transaction, rejectOnEmpty: true });
|
||||
group = await Group.findByPk(id, { rejectOnEmpty: true });
|
||||
|
||||
ctx.body = {
|
||||
data: {
|
||||
|
||||
@@ -115,16 +115,11 @@ 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, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
const integration = await Integration.findByPk(id);
|
||||
authorize(user, "update", integration);
|
||||
|
||||
if (integration.type === IntegrationType.Post) {
|
||||
@@ -135,7 +130,7 @@ router.post(
|
||||
|
||||
integration.settings = settings;
|
||||
|
||||
await integration.save({ transaction });
|
||||
await integration.save();
|
||||
|
||||
ctx.body = {
|
||||
data: presentIntegration(integration),
|
||||
@@ -157,7 +152,6 @@ router.post(
|
||||
const integration = await Integration.findByPk(id, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "delete", integration);
|
||||
|
||||
|
||||
@@ -124,16 +124,11 @@ router.post(
|
||||
"stars.update",
|
||||
auth(),
|
||||
validate(T.StarsUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.StarsUpdateReq>) => {
|
||||
const { id, index } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
let star = await Star.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
const { user } = ctx.state.auth;
|
||||
let star = await Star.findByPk(id);
|
||||
authorize(user, "update", star);
|
||||
|
||||
star = await starUpdater({
|
||||
@@ -141,7 +136,6 @@ router.post(
|
||||
star,
|
||||
ip: ctx.request.ip,
|
||||
index,
|
||||
transaction,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
@@ -155,19 +149,14 @@ router.post(
|
||||
"stars.delete",
|
||||
auth(),
|
||||
validate(T.StarsDeleteSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.StarsDeleteReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const star = await Star.findByPk(id, {
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
});
|
||||
const { user } = ctx.state.auth;
|
||||
const star = await Star.findByPk(id);
|
||||
authorize(user, "delete", star);
|
||||
|
||||
await starDestroyer({ user, star, ip: ctx.request.ip, transaction });
|
||||
await starDestroyer({ user, star, ip: ctx.request.ip });
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import escape from "escape-html";
|
||||
import { Context, Next } from "koa";
|
||||
import env from "@server/env";
|
||||
|
||||
/**
|
||||
* Resize observer script that sends a message to the parent window when content is resized. Inject
|
||||
@@ -118,38 +117,5 @@ ${resizeObserverScript(ctx)}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
parsed.host === "www.dropbox.com" &&
|
||||
parsed.protocol === "https:" &&
|
||||
ctx.path === "/embeds/dropbox"
|
||||
) {
|
||||
const dropboxJs = "https://www.dropbox.com/static/api/2/dropins.js";
|
||||
const csp = ctx.response.get("Content-Security-Policy");
|
||||
|
||||
// Inject Dropbox domain into the script-src directive
|
||||
ctx.set(
|
||||
"Content-Security-Policy",
|
||||
csp.replace("script-src", "script-src www.dropbox.com")
|
||||
);
|
||||
ctx.set("X-Frame-Options", "sameorigin");
|
||||
|
||||
ctx.type = "html";
|
||||
ctx.body = `
|
||||
<html>
|
||||
<head>
|
||||
<style>body { margin: 0; }</style>
|
||||
<base target="_parent">
|
||||
${iframeCheckScript(ctx)}
|
||||
</head>
|
||||
<body>
|
||||
<a href="${parsed}" class="dropbox-embed">
|
||||
<script type="text/javascript" src="${dropboxJs}"
|
||||
id="dropboxjs" data-app-key="${env.DROPBOX_APP_KEY}"></script>
|
||||
${resizeObserverScript(ctx)}
|
||||
</body>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
@@ -131,7 +131,6 @@ router.get("/s/:shareId/*", shareDomains(), renderShare);
|
||||
|
||||
router.get("/embeds/gitlab", renderEmbed);
|
||||
router.get("/embeds/github", renderEmbed);
|
||||
router.get("/embeds/dropbox", renderEmbed);
|
||||
|
||||
// catch all for application
|
||||
router.get("*", shareDomains(), async (ctx, next) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import isNull from "lodash/isNull";
|
||||
import randomstring from "randomstring";
|
||||
import { InferCreationAttributes } from "sequelize";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { DataAttributeDataType } from "@shared/models/types";
|
||||
import {
|
||||
CollectionPermission,
|
||||
FileOperationState,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
Team,
|
||||
User,
|
||||
Event,
|
||||
DataAttribute,
|
||||
Document,
|
||||
Star,
|
||||
Collection,
|
||||
@@ -402,19 +400,6 @@ export async function buildDocument(
|
||||
return document;
|
||||
}
|
||||
|
||||
export async function buildDataAttribute({
|
||||
userId,
|
||||
...overrides
|
||||
}: Partial<DataAttribute> & { userId: string }) {
|
||||
const dataAttribute = await DataAttribute.create({
|
||||
dataType: DataAttributeDataType.String,
|
||||
createdById: userId,
|
||||
name: faker.company.name(),
|
||||
...overrides,
|
||||
});
|
||||
return dataAttribute;
|
||||
}
|
||||
|
||||
export async function buildComment(overrides: {
|
||||
userId: string;
|
||||
documentId: string;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import * as React from "react";
|
||||
import Frame from "../components/Frame";
|
||||
import { EmbedProps as Props } from ".";
|
||||
|
||||
function Dropbox({ matches, ...props }: Props) {
|
||||
// "fi" = file
|
||||
// "fo" = folder
|
||||
// Files need more vertical space to be readable
|
||||
const embedHeight = matches[3].split("/")[0] === "fi" ? "550px" : "350px";
|
||||
|
||||
// Wrap inside an iframe to isolate external script and losened CSP
|
||||
return (
|
||||
<Frame
|
||||
src={`/embeds/dropbox?url=${encodeURIComponent(props.attrs.href)}`}
|
||||
className={props.isSelected ? "ProseMirror-selectednode" : ""}
|
||||
width="100%"
|
||||
height={embedHeight}
|
||||
title="Dropbox"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dropbox;
|
||||
@@ -1,14 +1,12 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { Primitive } from "utility-types";
|
||||
import env from "../../env";
|
||||
import { IntegrationService, IntegrationType } from "../../types";
|
||||
import type { IntegrationSettings } from "../../types";
|
||||
import { urlRegex } from "../../utils/urls";
|
||||
import Image from "../components/Img";
|
||||
import Berrycast from "./Berrycast";
|
||||
import Diagrams from "./Diagrams";
|
||||
import Dropbox from "./Dropbox";
|
||||
import Gist from "./Gist";
|
||||
import GitLabSnippet from "./GitLabSnippet";
|
||||
import InVision from "./InVision";
|
||||
@@ -230,19 +228,6 @@ const embeds: EmbedDescriptor[] = [
|
||||
`https://share.descript.com/embed/${matches[1]}`,
|
||||
icon: <Img src="/images/descript.png" alt="Descript" />,
|
||||
}),
|
||||
...(env.DROPBOX_APP_KEY
|
||||
? [
|
||||
new EmbedDescriptor({
|
||||
title: "Dropbox",
|
||||
keywords: "file document",
|
||||
regexMatch: [
|
||||
new RegExp("^https?://(www.)?dropbox.com/(s|scl)/(.*)$"),
|
||||
],
|
||||
icon: <Img src="/images/dropbox.png" alt="Dropbox" />,
|
||||
component: Dropbox,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
new EmbedDescriptor({
|
||||
title: "Figma",
|
||||
keywords: "design svg vector",
|
||||
|
||||
@@ -69,9 +69,7 @@
|
||||
"Create template": "Create template",
|
||||
"Open random document": "Open random document",
|
||||
"Search documents for \"{{searchQuery}}\"": "Search documents for \"{{searchQuery}}\"",
|
||||
"Move to workspace": "Move to workspace",
|
||||
"Move": "Move",
|
||||
"Move to collection": "Move to collection",
|
||||
"Move {{ documentType }}": "Move {{ documentType }}",
|
||||
"Archive": "Archive",
|
||||
"Document archived": "Document archived",
|
||||
@@ -207,6 +205,8 @@
|
||||
"{{ completed }} task done": "{{ completed }} task done",
|
||||
"{{ completed }} task done_plural": "{{ completed }} tasks done",
|
||||
"{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
|
||||
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
|
||||
"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.": "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.",
|
||||
"Currently editing": "Currently editing",
|
||||
"Currently viewing": "Currently viewing",
|
||||
"Viewed {{ timeAgo }}": "Viewed {{ timeAgo }}",
|
||||
@@ -355,10 +355,6 @@
|
||||
"No results": "No results",
|
||||
"Previous page": "Previous page",
|
||||
"Next page": "Next page",
|
||||
"Template created, go ahead and customize it": "Template created, go ahead and customize it",
|
||||
"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.": "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.",
|
||||
"Enable other members to use the template immediately": "Enable other members to use the template immediately",
|
||||
"Location": "Location",
|
||||
"Admins can manage the workspace and access billing.": "Admins can manage the workspace and access billing.",
|
||||
"Editors can create, edit, and comment on documents.": "Editors can create, edit, and comment on documents.",
|
||||
"Viewers can only view and comment on documents.": "Viewers can only view and comment on documents.",
|
||||
@@ -483,7 +479,7 @@
|
||||
"Manual sort": "Manual sort",
|
||||
"Comment options": "Comment options",
|
||||
"Edit attribute": "Edit attribute",
|
||||
"{{ documentName }} restored": "{{ documentName }} restored",
|
||||
"Document restored": "Document restored",
|
||||
"Document options": "Document options",
|
||||
"Restore": "Restore",
|
||||
"Choose a collection": "Choose a collection",
|
||||
@@ -495,7 +491,6 @@
|
||||
"Member options": "Member options",
|
||||
"New document in <em>{{ collectionName }}</em>": "New document in <em>{{ collectionName }}</em>",
|
||||
"New child document": "New child document",
|
||||
"Save in workspace": "Save in workspace",
|
||||
"Notification settings": "Notification settings",
|
||||
"Revision options": "Revision options",
|
||||
"Share link revoked": "Share link revoked",
|
||||
@@ -570,7 +565,6 @@
|
||||
"No resolved comments": "No resolved comments",
|
||||
"No comments yet": "No comments yet",
|
||||
"Error updating comment": "Error updating comment",
|
||||
"Document restored": "Document restored",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
|
||||
"{{ count }} comment": "{{ count }} comment",
|
||||
"{{ count }} comment_plural": "{{ count }} comments",
|
||||
|
||||
Reference in New Issue
Block a user