mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
99 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f277d08982 | |||
| 49d53ccfc2 | |||
| 98e44f528f | |||
| 0e79795856 | |||
| 09b2d0babe | |||
| 18821fdee2 | |||
| c8b12a59e2 | |||
| c964163cc5 | |||
| c9156ae399 | |||
| e0e87ea6a2 | |||
| e1b0e94fd5 | |||
| 2fa5e5c796 | |||
| 0882a50cfd | |||
| c85f3bd7b4 | |||
| 2d29f0f042 | |||
| 67d119f932 | |||
| 32b76303e5 | |||
| 212985e18f | |||
| b8115ae3ce | |||
| 264f19d255 | |||
| 6fc1cbc0ce | |||
| 3cc3cd8cf8 | |||
| b9f1fde2e3 | |||
| a1d4cca9d9 | |||
| 922bf53753 | |||
| 1c8fadbe02 | |||
| 4dbad4e46c | |||
| 24c71c38a5 | |||
| 354a68a8b7 | |||
| bb12f1fabb | |||
| debadcb711 | |||
| d2aea687f3 | |||
| 60309975e0 | |||
| 983010b5d8 | |||
| de5524d366 | |||
| c62bfc4a60 | |||
| 7804f33e0d | |||
| d17e6f3432 | |||
| 4f1277f912 | |||
| b172da6fdf | |||
| 138bc367dd | |||
| c657134b46 | |||
| 864f585e5b | |||
| a869ab7609 | |||
| a3d8e6c8fc | |||
| 68f24fce21 | |||
| f0cbbee4b8 | |||
| 7345d0c256 | |||
| ee05a8a0ca | |||
| fd7e0ef41f | |||
| 1e5cf2d960 | |||
| 421312b845 | |||
| f1bd4a5b31 | |||
| 72b0e78788 | |||
| 8302840ab5 | |||
| f32f07cdcc | |||
| f620a9d34c | |||
| 7113b5f604 | |||
| 41d7cc26b5 | |||
| e57941732a | |||
| a738b51d87 | |||
| 85dab03820 | |||
| ed8176ca7d | |||
| cfa7ecd7f8 | |||
| 44a4aee5cf | |||
| 7ead17a8e0 | |||
| 7a758f84a0 | |||
| 93bb9d067d | |||
| 9f3266abaf | |||
| 4d0473c22c | |||
| d8b4814aa9 | |||
| a326e0ee88 | |||
| 9338328a82 | |||
| 31931fc80c | |||
| 7deda03000 | |||
| 990de127e3 | |||
| 0c51bfb899 | |||
| 8e1f42a9cb | |||
| 1adcce6b5d | |||
| a5d611d544 | |||
| 1d242d44b1 | |||
| 7eaa8eb961 | |||
| 593cf73118 | |||
| e5c5e8907a | |||
| 5640ec30cc | |||
| da67486f2f | |||
| 8c39487c80 | |||
| 3ab9d7492e | |||
| 6a5d6ee3db | |||
| 57f9871c22 | |||
| dca491fc28 | |||
| e97cc61e2f | |||
| ba385e1507 | |||
| 71c9fcf59b | |||
| b45e6c504f | |||
| 1b00d51c74 | |||
| 7923a7e071 | |||
| b37a848914 | |||
| dca9bc1598 |
+14
-1
@@ -70,6 +70,15 @@ jobs:
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:app
|
||||
test-shared:
|
||||
<<: *defaults
|
||||
steps:
|
||||
- checkout
|
||||
- restore_cache:
|
||||
key: dependency-cache-{{ checksum "package.json" }}
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:shared
|
||||
test-server:
|
||||
<<: *defaults
|
||||
steps:
|
||||
@@ -81,7 +90,7 @@ jobs:
|
||||
command: ./node_modules/.bin/sequelize db:migrate --url $DATABASE_URL_TEST
|
||||
- run:
|
||||
name: test
|
||||
command: yarn test:server
|
||||
command: yarn test:server --forceExit
|
||||
bundle-size:
|
||||
<<: *defaults
|
||||
steps:
|
||||
@@ -140,6 +149,9 @@ workflows:
|
||||
- test-server:
|
||||
requires:
|
||||
- build
|
||||
- test-shared:
|
||||
requires:
|
||||
- build
|
||||
- test-app:
|
||||
requires:
|
||||
- build
|
||||
@@ -149,6 +161,7 @@ workflows:
|
||||
- bundle-size:
|
||||
requires:
|
||||
- test-app
|
||||
- test-shared
|
||||
- test-server
|
||||
|
||||
build-docker:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# –––––––––––––––– REQUIRED ––––––––––––––––
|
||||
|
||||
NODE_ENV=production
|
||||
|
||||
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
|
||||
# in your terminal to generate a random value.
|
||||
SECRET_KEY=generate_a_new_key
|
||||
@@ -168,3 +170,10 @@ SMTP_SECURE=true
|
||||
# The default interface language. See translate.getoutline.com for a list of
|
||||
# available language codes and their rough percentage translated.
|
||||
DEFAULT_LANGUAGE=en_US
|
||||
|
||||
# Optionally enable rate limiter at application web server
|
||||
RATE_LIMITER_ENABLED=true
|
||||
|
||||
# Configure default throttling parameters for rate limiter
|
||||
RATE_LIMITER_REQUESTS=1000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"rules": {
|
||||
"eqeqeq": 2,
|
||||
"curly": 2,
|
||||
"object-shorthand": "error",
|
||||
"no-mixed-operators": "off",
|
||||
"no-useless-escape": "off",
|
||||
"es/no-regexp-lookbehind-assertions": "error",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"projects": [
|
||||
{
|
||||
"displayName": "server",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/server"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"<rootDir>/server/test/env.ts"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/server/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "node",
|
||||
"runner": "@getoutline/jest-runner-serial"
|
||||
},
|
||||
{
|
||||
"displayName": "app",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/app/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
}
|
||||
},
|
||||
{
|
||||
"displayName": "shared-node",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"<rootDir>/shared/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "node"
|
||||
},
|
||||
{
|
||||
"displayName": "shared-jsdom",
|
||||
"verbose": false,
|
||||
"roots": [
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js",
|
||||
"^uuid$": "<rootDir>/node_modules/uuid/dist/index.js"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"testEnvironment": "jsdom",
|
||||
"testEnvironmentOptions": {
|
||||
"url": "http://localhost"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export default class Queue {
|
||||
name;
|
||||
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
process = (fn) => {
|
||||
console.log(`Registered function ${this.name}`);
|
||||
this.processFn = fn;
|
||||
};
|
||||
|
||||
add = (data) => {
|
||||
console.log(`Running ${this.name}`);
|
||||
return this.processFn({ data });
|
||||
};
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
// Mock for node-uuid
|
||||
global.console.warn = () => {};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"testURL": "http://localhost",
|
||||
"verbose": false,
|
||||
"rootDir": "..",
|
||||
"roots": [
|
||||
"<rootDir>/app",
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/app/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1",
|
||||
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
|
||||
},
|
||||
"moduleDirectories": [
|
||||
"node_modules"
|
||||
],
|
||||
"modulePaths": [
|
||||
"<rootDir>/app"
|
||||
],
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/window.js"
|
||||
],
|
||||
"setupFilesAfterEnv": [
|
||||
"./app/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "jsdom"
|
||||
}
|
||||
@@ -11,9 +11,18 @@ import {
|
||||
ImportIcon,
|
||||
PinIcon,
|
||||
SearchIcon,
|
||||
UnsubscribeIcon,
|
||||
SubscribeIcon,
|
||||
MoveIcon,
|
||||
TrashIcon,
|
||||
CrossIcon,
|
||||
ArchiveIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
@@ -108,6 +117,68 @@ export const unstarDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const subscribeDocument = createAction({
|
||||
name: ({ t }) => t("Subscribe"),
|
||||
section: DocumentSection,
|
||||
icon: <SubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).subscribe
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.subscribe();
|
||||
|
||||
stores.toasts.showToast(t("Subscribed to document notifications"), {
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const unsubscribeDocument = createAction({
|
||||
name: ({ t }) => t("Unsubscribe"),
|
||||
section: DocumentSection,
|
||||
icon: <UnsubscribeIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
return (
|
||||
!!document?.isSubscribed &&
|
||||
stores.policies.abilities(activeDocumentId).unsubscribe
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, currentUserId, t }) => {
|
||||
if (!activeDocumentId || !currentUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
|
||||
document?.unsubscribe(currentUserId);
|
||||
|
||||
stores.toasts.showToast(t("Unsubscribed from document notifications"), {
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
@@ -296,7 +367,7 @@ export const createTemplate = createAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update &&
|
||||
!document?.isTemplate &&
|
||||
@@ -329,15 +400,146 @@ export const searchDocumentsForQuery = (searchQuery: string) =>
|
||||
visible: ({ location }) => location.pathname !== searchPath(),
|
||||
});
|
||||
|
||||
export const moveDocument = createAction({
|
||||
name: ({ t }) => t("Move"),
|
||||
section: DocumentSection,
|
||||
icon: <MoveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).move;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
content: (
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const archiveDocument = createAction({
|
||||
name: ({ t }) => t("Archive"),
|
||||
section: DocumentSection,
|
||||
icon: <ArchiveIcon />,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).archive;
|
||||
},
|
||||
perform: async ({ activeDocumentId, stores, t }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.archive();
|
||||
stores.toasts.showToast(t("Document archived"), {
|
||||
type: "success",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteDocument = createAction({
|
||||
name: ({ t }) => t("Delete"),
|
||||
section: DocumentSection,
|
||||
icon: <TrashIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).delete;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const permanentlyDeleteDocument = createAction({
|
||||
name: ({ t }) => t("Permanently delete"),
|
||||
section: DocumentSection,
|
||||
icon: <CrossIcon />,
|
||||
dangerous: true,
|
||||
visible: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return false;
|
||||
}
|
||||
return !!stores.policies.abilities(activeDocumentId).permanentDelete;
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t }) => {
|
||||
if (activeDocumentId) {
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
stores.dialogs.openModal({
|
||||
title: t("Permanently delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
}),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<DocumentPermanentDelete
|
||||
document={document}
|
||||
onSubmit={stores.dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const rootDocumentActions = [
|
||||
openDocument,
|
||||
archiveDocument,
|
||||
createDocument,
|
||||
createTemplate,
|
||||
deleteDocument,
|
||||
importDocument,
|
||||
downloadDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
duplicateDocument,
|
||||
moveDocument,
|
||||
permanentlyDeleteDocument,
|
||||
printDocument,
|
||||
pinDocumentToCollection,
|
||||
pinDocumentToHome,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { createAction } from "~/actions";
|
||||
import { loadSessionsFromCookie } from "~/hooks/useSessions";
|
||||
|
||||
export const changeTeam = createAction({
|
||||
name: ({ t }) => t("Switch team"),
|
||||
placeholder: ({ t }) => t("Select a team"),
|
||||
keywords: "change workspace organization",
|
||||
section: "Account",
|
||||
visible: ({ currentTeamId }) => {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== currentTeamId
|
||||
);
|
||||
return otherSessions.length > 0;
|
||||
},
|
||||
children: ({ currentTeamId }) => {
|
||||
const sessions = loadSessionsFromCookie();
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== currentTeamId
|
||||
);
|
||||
|
||||
return otherSessions.map((session) => ({
|
||||
id: session.url,
|
||||
name: session.name,
|
||||
section: "Account",
|
||||
icon: <Logo alt={session.name} src={session.logoUrl} />,
|
||||
perform: () => (window.location.href = session.url),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const Logo = styled("img")`
|
||||
border-radius: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export const rootTeamActions = [changeTeam];
|
||||
@@ -56,6 +56,7 @@ export function actionToMenuItem(
|
||||
title,
|
||||
icon,
|
||||
visible,
|
||||
dangerous: action.dangerous,
|
||||
onClick: () => action.perform && action.perform(context),
|
||||
selected: action.selected ? action.selected(context) : undefined,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { rootDeveloperActions } from "./definitions/developer";
|
||||
import { rootDocumentActions } from "./definitions/documents";
|
||||
import { rootNavigationActions } from "./definitions/navigation";
|
||||
import { rootSettingsActions } from "./definitions/settings";
|
||||
import { rootTeamActions } from "./definitions/teams";
|
||||
import { rootUserActions } from "./definitions/users";
|
||||
|
||||
export default [
|
||||
@@ -12,4 +13,5 @@ export default [
|
||||
...rootNavigationActions,
|
||||
...rootSettingsActions,
|
||||
...rootDeveloperActions,
|
||||
...rootTeamActions,
|
||||
];
|
||||
|
||||
@@ -25,7 +25,7 @@ function CollectionDescription({ collection }: Props) {
|
||||
const [isExpanded, setExpanded] = React.useState(false);
|
||||
const [isEditing, setEditing] = React.useState(false);
|
||||
const [isDirty, setDirty] = React.useState(false);
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
const handleStartEditing = React.useCallback(() => {
|
||||
setEditing(true);
|
||||
|
||||
@@ -49,8 +49,8 @@ function DocumentListItem(
|
||||
ref: React.RefObject<HTMLAnchorElement>
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const currentUser = useCurrentUser();
|
||||
const currentTeam = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const team = useCurrentTeam();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
|
||||
const {
|
||||
@@ -70,7 +70,7 @@ function DocumentListItem(
|
||||
!!document.title.toLowerCase().includes(highlight.toLowerCase());
|
||||
const canStar =
|
||||
!document.isDraft && !document.isArchived && !document.isTemplate;
|
||||
const can = usePolicy(currentTeam.id);
|
||||
const can = usePolicy(team);
|
||||
const canCollection = usePolicy(document.collectionId);
|
||||
|
||||
return (
|
||||
@@ -96,7 +96,7 @@ function DocumentListItem(
|
||||
highlight={highlight}
|
||||
dir={document.dir}
|
||||
/>
|
||||
{document.isBadgedNew && document.createdBy.id !== currentUser.id && (
|
||||
{document.isBadgedNew && document.createdBy.id !== user.id && (
|
||||
<Badge yellow>{t("New")}</Badge>
|
||||
)}
|
||||
{canStar && (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import mergeRefs from "react-merge-refs";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
@@ -18,6 +18,7 @@ import ErrorBoundary from "~/components/ErrorBoundary";
|
||||
import HoverPreview from "~/components/HoverPreview";
|
||||
import type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||
import useDictionary from "~/hooks/useDictionary";
|
||||
import useEmbeds from "~/hooks/useEmbeds";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { NotFoundError } from "~/utils/errors";
|
||||
@@ -58,6 +59,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const [
|
||||
activeLinkEvent,
|
||||
setActiveLinkEvent,
|
||||
@@ -310,4 +312,4 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
);
|
||||
}
|
||||
|
||||
export default React.forwardRef(Editor);
|
||||
export default observer(React.forwardRef(Editor));
|
||||
|
||||
@@ -33,7 +33,7 @@ type Props = {
|
||||
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const can = usePolicy(document.id);
|
||||
const can = usePolicy(document);
|
||||
const opts = {
|
||||
userName: event.actor.name,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { $Diff } from "utility-types";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import InputSelect, { Props, Option } from "./InputSelect";
|
||||
|
||||
export default function InputSelectPermission(
|
||||
@@ -31,11 +32,11 @@ export default function InputSelectPermission(
|
||||
options={[
|
||||
{
|
||||
label: t("View and edit"),
|
||||
value: "read_write",
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
{
|
||||
label: t("View only"),
|
||||
value: "read",
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("No access"),
|
||||
|
||||
@@ -36,7 +36,7 @@ function AppSidebar() {
|
||||
const { documents } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!user.isViewer) {
|
||||
|
||||
@@ -44,7 +44,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
const { dialogs, documents, collections } = useStores();
|
||||
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const canUpdate = usePolicy(collection.id).update;
|
||||
const canUpdate = usePolicy(collection).update;
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const inStarredSection = useStarredContext();
|
||||
|
||||
@@ -25,7 +25,7 @@ function CollectionLinkChildren({
|
||||
expanded,
|
||||
prefetchDocument,
|
||||
}: Props) {
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
const { showToast } = useToasts();
|
||||
const manualSort = collection.sort.field === "index";
|
||||
const { documents } = useStores();
|
||||
|
||||
@@ -39,7 +39,7 @@ function DraggableCollectionLink({
|
||||
const [expanded, setExpanded] = React.useState(
|
||||
collection.id === ui.activeCollectionId && !locationStateStarred
|
||||
);
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
const belowCollectionIndex = belowCollection ? belowCollection.index : null;
|
||||
|
||||
// Drop to reorder collection
|
||||
|
||||
@@ -1,391 +0,0 @@
|
||||
import invariant from "invariant";
|
||||
import { find } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import withStores from "~/components/withStores";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
|
||||
|
||||
type SocketWithAuthentication = Socket & {
|
||||
authenticated?: boolean;
|
||||
};
|
||||
|
||||
export const SocketContext = React.createContext<SocketWithAuthentication | null>(
|
||||
null
|
||||
);
|
||||
|
||||
type Props = RootStore;
|
||||
|
||||
@observer
|
||||
class SocketProvider extends React.Component<Props> {
|
||||
@observable
|
||||
socket: SocketWithAuthentication | null;
|
||||
|
||||
componentDidMount() {
|
||||
this.createConnection();
|
||||
document.addEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = false;
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
document.removeEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
checkConnection = () => {
|
||||
if (this.socket?.disconnected && getPageVisible()) {
|
||||
// null-ifying this reference is important, do not remove. Without it
|
||||
// references to old sockets are potentially held in context
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.createConnection();
|
||||
}
|
||||
};
|
||||
|
||||
createConnection = () => {
|
||||
this.socket = io(window.location.origin, {
|
||||
path: "/realtime",
|
||||
transports: ["websocket"],
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
});
|
||||
invariant(this.socket, "Socket should be defined");
|
||||
|
||||
this.socket.authenticated = false;
|
||||
const {
|
||||
auth,
|
||||
toasts,
|
||||
documents,
|
||||
collections,
|
||||
groups,
|
||||
pins,
|
||||
stars,
|
||||
memberships,
|
||||
policies,
|
||||
presence,
|
||||
views,
|
||||
fileOperations,
|
||||
} = this.props;
|
||||
if (!auth.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
// immediately send current users token to the websocket backend where it
|
||||
// is verified, if all goes well an 'authenticated' message will be
|
||||
// received in response
|
||||
this.socket?.emit("authentication", {
|
||||
token: auth.token,
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("disconnect", () => {
|
||||
// when the socket is disconnected we need to clear all presence state as
|
||||
// it's no longer reliable.
|
||||
presence.clear();
|
||||
});
|
||||
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
this.socket.io.on("reconnect_attempt", () => {
|
||||
if (this.socket) {
|
||||
this.socket.io.opts.transports = auth?.team?.domain
|
||||
? ["websocket"]
|
||||
: ["websocket", "polling"];
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", () => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("unauthorized", (err: Error) => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = false;
|
||||
}
|
||||
toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
this.socket.on("entities", async (event: any) => {
|
||||
if (event.documentIds) {
|
||||
for (const documentDescriptor of event.documentIds) {
|
||||
const documentId = documentDescriptor.id;
|
||||
let document = documents.get(documentId) || {};
|
||||
|
||||
if (event.event === "documents.delete") {
|
||||
const document = documents.get(documentId);
|
||||
|
||||
if (document) {
|
||||
document.deletedAt = documentDescriptor.updatedAt;
|
||||
}
|
||||
|
||||
policies.remove(documentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
|
||||
const { title, updatedAt } = document;
|
||||
|
||||
if (updatedAt === documentDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// otherwise, grab the latest version of the document
|
||||
try {
|
||||
document = await documents.fetch(documentId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.remove(documentId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if the title changed then we need to update the collection also
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'title' does not exist on type '{}'.
|
||||
if (title !== document.title) {
|
||||
if (!event.collectionIds) {
|
||||
event.collectionIds = [];
|
||||
}
|
||||
|
||||
const existing = find(event.collectionIds, {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
|
||||
id: document.collectionId,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
event.collectionIds.push({
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'collectionId' does not exist on type '{}... Remove this comment to see the full error message
|
||||
id: document.collectionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.collectionIds) {
|
||||
for (const collectionDescriptor of event.collectionIds) {
|
||||
const collectionId = collectionDescriptor.id;
|
||||
const collection = collections.get(collectionId);
|
||||
|
||||
if (event.event === "collections.delete") {
|
||||
if (collection) {
|
||||
collection.deletedAt = collectionDescriptor.updatedAt;
|
||||
}
|
||||
|
||||
const deletedDocuments = documents.inCollection(collectionId);
|
||||
deletedDocuments.forEach((doc) => {
|
||||
doc.deletedAt = collectionDescriptor.updatedAt;
|
||||
policies.remove(doc.id);
|
||||
});
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
|
||||
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await collections.fetch(collectionId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.groupIds) {
|
||||
for (const groupDescriptor of event.groupIds) {
|
||||
const groupId = groupDescriptor.id;
|
||||
const group = groups.get(groupId) || {};
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'updatedAt' does not exist on type '{}'.
|
||||
const { updatedAt } = group;
|
||||
|
||||
if (updatedAt === groupDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await groups.fetch(groupId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
groups.remove(groupId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.teamIds) {
|
||||
await auth.fetch();
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("pins.create", (event: any) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.update", (event: any) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.delete", (event: any) => {
|
||||
pins.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("stars.create", (event: any) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.update", (event: any) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.delete", (event: any) => {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("documents.permanent_delete", (event: any) => {
|
||||
documents.remove(event.documentId);
|
||||
});
|
||||
|
||||
// received when a user is given access to a collection
|
||||
// if the user is us then we go ahead and load the collection from API.
|
||||
this.socket.on("collections.add_user", (event: any) => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.fetch(event.collectionId, {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Document policies might need updating as the permission changes
|
||||
documents.inCollection(event.collectionId).forEach((document) => {
|
||||
policies.remove(document.id);
|
||||
});
|
||||
});
|
||||
|
||||
// received when a user is removed from having access to a collection
|
||||
// to keep state in sync we must update our UI if the user is us,
|
||||
// or otherwise just remove any membership state we have for that user.
|
||||
this.socket.on("collections.remove_user", (event: any) => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.remove(event.collectionId);
|
||||
memberships.removeCollectionMemberships(event.collectionId);
|
||||
documents.removeCollectionDocuments(event.collectionId);
|
||||
} else {
|
||||
memberships.remove(`${event.userId}-${event.collectionId}`);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("collections.update_index", (event: any) => {
|
||||
const collection = collections.get(event.collectionId);
|
||||
|
||||
if (collection) {
|
||||
collection.updateIndex(event.index);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("fileOperations.create", async (event: any) => {
|
||||
const user = auth.user;
|
||||
if (user) {
|
||||
fileOperations.add({ ...event, user });
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("fileOperations.update", async (event: any) => {
|
||||
const user = auth.user;
|
||||
if (user) {
|
||||
fileOperations.add({ ...event, user });
|
||||
}
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to join a specific room. Forward that to the ws server.
|
||||
this.socket.on("join", (event: any) => {
|
||||
this.socket?.emit("join", event);
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to leave a specific room. Forward that to the ws server.
|
||||
this.socket.on("leave", (event: any) => {
|
||||
this.socket?.emit("leave", event);
|
||||
});
|
||||
|
||||
// received whenever we join a document room, the payload includes
|
||||
// userIds that are present/viewing and those that are editing.
|
||||
this.socket.on("document.presence", (event: any) => {
|
||||
presence.init(event.documentId, event.userIds, event.editingIds);
|
||||
});
|
||||
|
||||
// received whenever a new user joins a document room, aka they
|
||||
// navigate to / start viewing a document
|
||||
this.socket.on("user.join", (event: any) => {
|
||||
presence.touch(event.documentId, event.userId, event.isEditing);
|
||||
views.touch(event.documentId, event.userId);
|
||||
});
|
||||
|
||||
// received whenever a new user leaves a document room, aka they
|
||||
// navigate away / stop viewing a document
|
||||
this.socket.on("user.leave", (event: any) => {
|
||||
presence.leave(event.documentId, event.userId);
|
||||
views.touch(event.documentId, event.userId);
|
||||
});
|
||||
|
||||
// received when another client in a document room wants to change
|
||||
// or update it's presence. Currently the only property is whether
|
||||
// the client is in editing state or not.
|
||||
this.socket.on("user.presence", (event: any) => {
|
||||
presence.touch(event.documentId, event.userId, event.isEditing);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SocketContext.Provider value={this.socket}>
|
||||
{this.props.children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStores(SocketProvider);
|
||||
@@ -0,0 +1,448 @@
|
||||
import invariant from "invariant";
|
||||
import { find } from "lodash";
|
||||
import { action, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import Group from "~/models/Group";
|
||||
import Pin from "~/models/Pin";
|
||||
import Star from "~/models/Star";
|
||||
import Subscription from "~/models/Subscription";
|
||||
import Team from "~/models/Team";
|
||||
import withStores from "~/components/withStores";
|
||||
import {
|
||||
PartialWithId,
|
||||
WebsocketCollectionUpdateIndexEvent,
|
||||
WebsocketCollectionUserEvent,
|
||||
WebsocketEntitiesEvent,
|
||||
WebsocketEntityDeletedEvent,
|
||||
} from "~/types";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
|
||||
|
||||
type SocketWithAuthentication = Socket & {
|
||||
authenticated?: boolean;
|
||||
};
|
||||
|
||||
export const WebsocketContext = React.createContext<SocketWithAuthentication | null>(
|
||||
null
|
||||
);
|
||||
|
||||
type Props = RootStore;
|
||||
|
||||
@observer
|
||||
class WebsocketProvider extends React.Component<Props> {
|
||||
@observable
|
||||
socket: SocketWithAuthentication | null;
|
||||
|
||||
componentDidMount() {
|
||||
this.createConnection();
|
||||
document.addEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = false;
|
||||
this.socket.disconnect();
|
||||
}
|
||||
|
||||
document.removeEventListener(getVisibilityListener(), this.checkConnection);
|
||||
}
|
||||
|
||||
checkConnection = () => {
|
||||
if (this.socket?.disconnected && getPageVisible()) {
|
||||
// null-ifying this reference is important, do not remove. Without it
|
||||
// references to old sockets are potentially held in context
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
this.createConnection();
|
||||
}
|
||||
};
|
||||
|
||||
createConnection = () => {
|
||||
this.socket = io(window.location.origin, {
|
||||
path: "/realtime",
|
||||
transports: ["websocket"],
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
});
|
||||
invariant(this.socket, "Socket should be defined");
|
||||
|
||||
this.socket.authenticated = false;
|
||||
const {
|
||||
auth,
|
||||
toasts,
|
||||
documents,
|
||||
collections,
|
||||
groups,
|
||||
pins,
|
||||
stars,
|
||||
memberships,
|
||||
policies,
|
||||
presence,
|
||||
views,
|
||||
subscriptions,
|
||||
fileOperations,
|
||||
} = this.props;
|
||||
if (!auth.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.socket.on("connect", () => {
|
||||
// immediately send current users token to the websocket backend where it
|
||||
// is verified, if all goes well an 'authenticated' message will be
|
||||
// received in response
|
||||
this.socket?.emit("authentication", {
|
||||
token: auth.token,
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("disconnect", () => {
|
||||
// when the socket is disconnected we need to clear all presence state as
|
||||
// it's no longer reliable.
|
||||
presence.clear();
|
||||
});
|
||||
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
this.socket.io.on("reconnect_attempt", () => {
|
||||
if (this.socket) {
|
||||
this.socket.io.opts.transports = auth?.team?.domain
|
||||
? ["websocket"]
|
||||
: ["websocket", "polling"];
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("authenticated", () => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = true;
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on("unauthorized", (err: Error) => {
|
||||
if (this.socket) {
|
||||
this.socket.authenticated = false;
|
||||
}
|
||||
toasts.showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"entities",
|
||||
action(async (event: WebsocketEntitiesEvent) => {
|
||||
if (event.documentIds) {
|
||||
for (const documentDescriptor of event.documentIds) {
|
||||
const documentId = documentDescriptor.id;
|
||||
let document = documents.get(documentId);
|
||||
const previousTitle = document?.title;
|
||||
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
if (document?.updatedAt === documentDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// otherwise, grab the latest version of the document
|
||||
try {
|
||||
document = await documents.fetch(documentId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.remove(documentId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// if the title changed then we need to update the collection also
|
||||
if (document && previousTitle !== document.title) {
|
||||
if (!event.collectionIds) {
|
||||
event.collectionIds = [];
|
||||
}
|
||||
|
||||
const existing = find(event.collectionIds, {
|
||||
id: document.collectionId,
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
event.collectionIds.push({
|
||||
id: document.collectionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.collectionIds) {
|
||||
for (const collectionDescriptor of event.collectionIds) {
|
||||
const collectionId = collectionDescriptor.id;
|
||||
const collection = collections.get(collectionId);
|
||||
|
||||
// if we already have the latest version (it was us that performed
|
||||
// the change) then we don't need to update anything either.
|
||||
if (collection?.updatedAt === collectionDescriptor.updatedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await collections.fetch(collectionId, {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.update",
|
||||
action(
|
||||
(event: PartialWithId<Document> & { title: string; url: string }) => {
|
||||
documents.add(event);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.updateDocument(event);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.archive",
|
||||
action((event: PartialWithId<Document>) => {
|
||||
documents.add(event);
|
||||
policies.remove(event.id);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.removeDocument(event.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.delete",
|
||||
action((event: PartialWithId<Document>) => {
|
||||
documents.add(event);
|
||||
policies.remove(event.id);
|
||||
|
||||
if (event.collectionId) {
|
||||
const collection = collections.get(event.collectionId);
|
||||
collection?.removeDocument(event.id);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"documents.permanent_delete",
|
||||
(event: WebsocketEntityDeletedEvent) => {
|
||||
documents.remove(event.modelId);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
||||
groups.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("groups.update", (event: PartialWithId<Group>) => {
|
||||
groups.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("groups.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
groups.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("collections.create", (event: PartialWithId<Collection>) => {
|
||||
collections.add(event);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"collections.delete",
|
||||
action((event: WebsocketEntityDeletedEvent) => {
|
||||
const collectionId = event.modelId;
|
||||
const deletedAt = new Date().toISOString();
|
||||
|
||||
const deletedDocuments = documents.inCollection(collectionId);
|
||||
deletedDocuments.forEach((doc) => {
|
||||
doc.deletedAt = deletedAt;
|
||||
policies.remove(doc.id);
|
||||
});
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
policies.remove(collectionId);
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on("teams.update", (event: PartialWithId<Team>) => {
|
||||
auth.updateTeam(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.create", (event: PartialWithId<Pin>) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.update", (event: PartialWithId<Pin>) => {
|
||||
pins.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("pins.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
pins.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on("stars.create", (event: PartialWithId<Star>) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.update", (event: PartialWithId<Star>) => {
|
||||
stars.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("stars.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
// received when a user is given access to a collection
|
||||
// if the user is us then we go ahead and load the collection from API.
|
||||
this.socket.on(
|
||||
"collections.add_user",
|
||||
action((event: WebsocketCollectionUserEvent) => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.fetch(event.collectionId, {
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Document policies might need updating as the permission changes
|
||||
documents.inCollection(event.collectionId).forEach((document) => {
|
||||
policies.remove(document.id);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
// received when a user is removed from having access to a collection
|
||||
// to keep state in sync we must update our UI if the user is us,
|
||||
// or otherwise just remove any membership state we have for that user.
|
||||
this.socket.on(
|
||||
"collections.remove_user",
|
||||
action((event: WebsocketCollectionUserEvent) => {
|
||||
if (auth.user && event.userId === auth.user.id) {
|
||||
collections.remove(event.collectionId);
|
||||
memberships.removeCollectionMemberships(event.collectionId);
|
||||
documents.removeCollectionDocuments(event.collectionId);
|
||||
} else {
|
||||
memberships.remove(`${event.userId}-${event.collectionId}`);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"collections.update_index",
|
||||
action((event: WebsocketCollectionUpdateIndexEvent) => {
|
||||
const collection = collections.get(event.collectionId);
|
||||
|
||||
if (collection) {
|
||||
collection.updateIndex(event.index);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"fileOperations.create",
|
||||
(event: PartialWithId<FileOperation>) => {
|
||||
fileOperations.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"fileOperations.update",
|
||||
(event: PartialWithId<FileOperation>) => {
|
||||
fileOperations.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"subscriptions.create",
|
||||
(event: PartialWithId<Subscription>) => {
|
||||
subscriptions.add(event);
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on(
|
||||
"subscriptions.delete",
|
||||
(event: WebsocketEntityDeletedEvent) => {
|
||||
subscriptions.remove(event.modelId);
|
||||
}
|
||||
);
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to join a specific room. Forward that to the ws server.
|
||||
this.socket.on("join", (event: any) => {
|
||||
this.socket?.emit("join", event);
|
||||
});
|
||||
|
||||
// received a message from the API server that we should request
|
||||
// to leave a specific room. Forward that to the ws server.
|
||||
this.socket.on("leave", (event: any) => {
|
||||
this.socket?.emit("leave", event);
|
||||
});
|
||||
|
||||
// received whenever we join a document room, the payload includes
|
||||
// userIds that are present/viewing and those that are editing.
|
||||
this.socket.on("document.presence", (event: any) => {
|
||||
presence.init(event.documentId, event.userIds, event.editingIds);
|
||||
});
|
||||
|
||||
// received whenever a new user joins a document room, aka they
|
||||
// navigate to / start viewing a document
|
||||
this.socket.on("user.join", (event: any) => {
|
||||
presence.touch(event.documentId, event.userId, event.isEditing);
|
||||
views.touch(event.documentId, event.userId);
|
||||
});
|
||||
|
||||
// received whenever a new user leaves a document room, aka they
|
||||
// navigate away / stop viewing a document
|
||||
this.socket.on("user.leave", (event: any) => {
|
||||
presence.leave(event.documentId, event.userId);
|
||||
views.touch(event.documentId, event.userId);
|
||||
});
|
||||
|
||||
// received when another client in a document room wants to change
|
||||
// or update it's presence. Currently the only property is whether
|
||||
// the client is in editing state or not.
|
||||
this.socket.on("user.presence", (event: any) => {
|
||||
presence.touch(event.documentId, event.userId, event.isEditing);
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<WebsocketContext.Provider value={this.socket}>
|
||||
{this.props.children}
|
||||
</WebsocketContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withStores(WebsocketProvider);
|
||||
@@ -7,9 +7,10 @@ import { Portal } from "react-portal";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { EmbedDescriptor, MenuItem } from "@shared/editor/types";
|
||||
import { MenuItem } from "@shared/editor/types";
|
||||
import { depths } from "@shared/styles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
@@ -427,10 +428,12 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title) {
|
||||
embedItems.push({
|
||||
...embed,
|
||||
name: "embed",
|
||||
});
|
||||
embedItems.push(
|
||||
new EmbedDescriptor({
|
||||
...embed,
|
||||
name: "embed",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { setTextSelection } from "prosemirror-utils";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { isInternalUrl, sanitizeHref } from "@shared/utils/urls";
|
||||
import { isInternalUrl, sanitizeUrl } from "@shared/utils/urls";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import { ToastOptions } from "~/types";
|
||||
@@ -70,7 +70,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
get href(): string {
|
||||
return sanitizeHref(this.props.mark?.attrs.href) ?? "";
|
||||
return sanitizeUrl(this.props.mark?.attrs.href) ?? "";
|
||||
}
|
||||
|
||||
get suggestedLinkTitle(): string {
|
||||
@@ -113,7 +113,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
|
||||
this.discardInputValue = true;
|
||||
const { from, to } = this.props;
|
||||
href = sanitizeHref(href) ?? "";
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
this.props.onSelectLink({ href, title, from, to });
|
||||
};
|
||||
|
||||
@@ -63,6 +63,15 @@ const EditorStyles = styled.div<{
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
// all of heading sizes are stepped down one from global styles, except h1
|
||||
// which is between h1 and h2
|
||||
h1 { font-size: 1.75em; }
|
||||
h2 { font-size: 1.25em; }
|
||||
h3 { font-size: 1em; }
|
||||
h4 { font-size: 0.875em; }
|
||||
h5 { font-size: 0.75em; }
|
||||
h6 { font-size: 0.75em; }
|
||||
|
||||
.ProseMirror-yjs-cursor {
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
@@ -1266,6 +1275,7 @@ const EditorStyles = styled.div<{
|
||||
.placeholder:before,
|
||||
.block-menu-trigger,
|
||||
.heading-actions,
|
||||
button.show-source-button,
|
||||
h1:not(.placeholder):before,
|
||||
h2:not(.placeholder):before,
|
||||
h3:not(.placeholder):before,
|
||||
@@ -1279,6 +1289,11 @@ const EditorStyles = styled.div<{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: hidden;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
em,
|
||||
blockquote {
|
||||
font-family: "SF Pro Text", ${(props) => props.theme.fontFamily};
|
||||
|
||||
@@ -16,6 +16,7 @@ import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
|
||||
import { Decoration, EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { DefaultTheme, ThemeProps } from "styled-components";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import getHeadings from "@shared/editor/lib/getHeadings";
|
||||
@@ -25,8 +26,10 @@ import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import fullExtensionsPackage from "@shared/editor/packages/full";
|
||||
import { EmbedDescriptor, EventType } from "@shared/editor/types";
|
||||
import { EventType } from "@shared/editor/types";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Integration from "~/models/Integration";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
@@ -110,6 +113,8 @@ export type Props = {
|
||||
onShowToast: (message: string) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
|
||||
embedIntegrations?: Integration<IntegrationType.Embed>[];
|
||||
};
|
||||
|
||||
type State = {
|
||||
@@ -430,7 +435,7 @@ export class Editor extends React.PureComponent<
|
||||
state: this.createState(this.props.value),
|
||||
editable: () => !this.props.readOnly,
|
||||
nodeViews: this.nodeViews,
|
||||
dispatchTransaction: function (transaction) {
|
||||
dispatchTransaction(transaction) {
|
||||
// callback is bound to have the view instance as its this binding
|
||||
const { state, transactions } = this.state.applyTransaction(
|
||||
transaction
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
LinkIcon,
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
BuildingBlocksIcon,
|
||||
DownloadIcon,
|
||||
WebhooksIcon,
|
||||
} from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Details from "~/scenes/Settings/Details";
|
||||
import Drawio from "~/scenes/Settings/Drawio";
|
||||
import Export from "~/scenes/Settings/Export";
|
||||
import Features from "~/scenes/Settings/Features";
|
||||
import Groups from "~/scenes/Settings/Groups";
|
||||
@@ -67,7 +69,7 @@ type ConfigType = {
|
||||
|
||||
const useAuthorizedSettingsConfig = () => {
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const config: ConfigType = React.useMemo(
|
||||
@@ -170,6 +172,14 @@ const useAuthorizedSettingsConfig = () => {
|
||||
group: t("Integrations"),
|
||||
icon: WebhooksIcon,
|
||||
},
|
||||
Drawio: {
|
||||
name: t("Draw.io"),
|
||||
path: "/settings/integrations/drawio",
|
||||
component: Drawio,
|
||||
enabled: can.update,
|
||||
group: t("Integrations"),
|
||||
icon: BuildingBlocksIcon,
|
||||
},
|
||||
Slack: {
|
||||
name: "Slack",
|
||||
path: "/settings/integrations/slack",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { find } from "lodash";
|
||||
import * as React from "react";
|
||||
import embeds, { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import Logger from "~/utils/Logger";
|
||||
import useStores from "./useStores";
|
||||
|
||||
/**
|
||||
* Hook to get all embed configuration for the current team
|
||||
*
|
||||
* @param loadIfMissing Should we load integration settings if they are not
|
||||
* locally available
|
||||
* @returns A list of embed descriptors
|
||||
*/
|
||||
export default function useEmbeds(loadIfMissing = false) {
|
||||
const { integrations } = useStores();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchEmbedIntegrations() {
|
||||
try {
|
||||
await integrations.fetchPage({
|
||||
limit: 100,
|
||||
type: IntegrationType.Embed,
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Failed to fetch embed integrations", err);
|
||||
}
|
||||
}
|
||||
|
||||
!integrations.isLoaded && loadIfMissing && fetchEmbedIntegrations();
|
||||
}, [integrations, loadIfMissing]);
|
||||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
embeds.map((e) => {
|
||||
const em: Integration<IntegrationType.Embed> | undefined = find(
|
||||
integrations.orderedData,
|
||||
(i) => i.service === e.component.name.toLowerCase()
|
||||
);
|
||||
return new EmbedDescriptor({
|
||||
...e,
|
||||
settings: em?.settings,
|
||||
});
|
||||
}),
|
||||
[integrations.orderedData]
|
||||
);
|
||||
}
|
||||
+26
-4
@@ -1,12 +1,34 @@
|
||||
import * as React from "react";
|
||||
import BaseModel from "~/models/BaseModel";
|
||||
import useStores from "./useStores";
|
||||
|
||||
/**
|
||||
* Quick access to retrieve the abilities of a policy for a given entity
|
||||
* Retrieve the abilities of a policy for a given entity, if the policy is not
|
||||
* located in the store, it will be fetched from the server.
|
||||
*
|
||||
* @param entityId The entity id
|
||||
* @returns The available abilities
|
||||
* @param entity The model or model id
|
||||
* @returns The policy for the model
|
||||
*/
|
||||
export default function usePolicy(entityId: string) {
|
||||
export default function usePolicy(entity: string | BaseModel | undefined) {
|
||||
const { policies } = useStores();
|
||||
const triggered = React.useRef(false);
|
||||
const entityId = entity
|
||||
? typeof entity === "string"
|
||||
? entity
|
||||
: entity.id
|
||||
: "";
|
||||
|
||||
React.useEffect(() => {
|
||||
if (entity && typeof entity !== "string") {
|
||||
// The policy for this model is missing and we haven't tried to fetch it
|
||||
// yet, go ahead and do that now. The force flag is needed otherwise the
|
||||
// network request will be skipped due to the model existing in the store
|
||||
if (!policies.get(entity.id) && !triggered.current) {
|
||||
triggered.current = true;
|
||||
void entity.store.fetch(entity.id, { force: true });
|
||||
}
|
||||
}
|
||||
}, [policies, entity]);
|
||||
|
||||
return policies.abilities(entityId);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ type Session = {
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
function loadSessionsFromCookie(): Session[] {
|
||||
export function loadSessionsFromCookie(): Session[] {
|
||||
const sessions = JSON.parse(getCookie("sessions") || "{}");
|
||||
return Object.keys(sessions).map((teamId) => ({
|
||||
teamId,
|
||||
|
||||
@@ -187,8 +187,8 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const alphabeticalSort = collection.sort.field === "title";
|
||||
const can = usePolicy(collection.id);
|
||||
const canUserInTeam = usePolicy(team.id);
|
||||
const can = usePolicy(collection);
|
||||
const canUserInTeam = usePolicy(team);
|
||||
const items: MenuItem[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
|
||||
+30
-192
@@ -1,20 +1,11 @@
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
EditIcon,
|
||||
StarredIcon,
|
||||
UnstarredIcon,
|
||||
DuplicateIcon,
|
||||
ArchiveIcon,
|
||||
TrashIcon,
|
||||
MoveIcon,
|
||||
HistoryIcon,
|
||||
UnpublishIcon,
|
||||
PrintIcon,
|
||||
ImportIcon,
|
||||
NewDocumentIcon,
|
||||
DownloadIcon,
|
||||
RestoreIcon,
|
||||
CrossIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,19 +16,29 @@ import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||
import DocumentMove from "~/scenes/DocumentMove";
|
||||
import DocumentPermanentDelete from "~/scenes/DocumentPermanentDelete";
|
||||
import CollectionIcon from "~/components/CollectionIcon";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import Flex from "~/components/Flex";
|
||||
import Modal from "~/components/Modal";
|
||||
import Switch from "~/components/Switch";
|
||||
import { actionToMenuItem } from "~/actions";
|
||||
import { pinDocument, createTemplate } from "~/actions/definitions/documents";
|
||||
import {
|
||||
pinDocument,
|
||||
createTemplate,
|
||||
subscribeDocument,
|
||||
unsubscribeDocument,
|
||||
moveDocument,
|
||||
deleteDocument,
|
||||
permanentlyDeleteDocument,
|
||||
downloadDocument,
|
||||
importDocument,
|
||||
starDocument,
|
||||
unstarDocument,
|
||||
duplicateDocument,
|
||||
archiveDocument,
|
||||
} from "~/actions/definitions/documents";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -94,39 +95,8 @@ function DocumentMenu({
|
||||
});
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
|
||||
const [
|
||||
showPermanentDeleteModal,
|
||||
setShowPermanentDeleteModal,
|
||||
] = React.useState(false);
|
||||
const [showMoveModal, setShowMoveModal] = React.useState(false);
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setRenderModals(true);
|
||||
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}, [onOpen]);
|
||||
|
||||
const handleDuplicate = React.useCallback(async () => {
|
||||
const duped = await document.duplicate();
|
||||
// when duplicating, go straight to the duplicated document content
|
||||
history.push(duped.url);
|
||||
showToast(t("Document duplicated"), {
|
||||
type: "success",
|
||||
});
|
||||
}, [t, history, showToast, document]);
|
||||
|
||||
const handleArchive = React.useCallback(async () => {
|
||||
await document.archive();
|
||||
showToast(t("Document archived"), {
|
||||
type: "success",
|
||||
});
|
||||
}, [showToast, t, document]);
|
||||
|
||||
const handleRestore = React.useCallback(
|
||||
async (
|
||||
ev: React.SyntheticEvent,
|
||||
@@ -154,26 +124,8 @@ function DocumentMenu({
|
||||
window.print();
|
||||
}, [menu]);
|
||||
|
||||
const handleStar = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.star();
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
const handleUnstar = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
document.unstar();
|
||||
},
|
||||
[document]
|
||||
);
|
||||
|
||||
const collection = collections.get(document.collectionId);
|
||||
const can = usePolicy(document.id);
|
||||
const can = usePolicy(document);
|
||||
const canViewHistory = can.read && !can.restore;
|
||||
const restoreItems = React.useMemo(
|
||||
() => [
|
||||
@@ -205,19 +157,6 @@ function DocumentMenu({
|
||||
ev.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleImportDocument = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
// simulate a click on the file upload input element
|
||||
if (file.current) {
|
||||
file.current.click();
|
||||
}
|
||||
},
|
||||
[file]
|
||||
);
|
||||
|
||||
const handleFilePicked = React.useCallback(
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = getEventFiles(ev);
|
||||
@@ -280,7 +219,7 @@ function DocumentMenu({
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Document options")}
|
||||
onOpen={handleOpen}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
@@ -313,22 +252,10 @@ function DocumentMenu({
|
||||
...restoreItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Unstar"),
|
||||
onClick: handleUnstar,
|
||||
visible: document.isStarred && !!can.unstar,
|
||||
icon: <UnstarredIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Star"),
|
||||
onClick: handleStar,
|
||||
visible: !document.isStarred && !!can.star,
|
||||
icon: <StarredIcon />,
|
||||
},
|
||||
// Pin document
|
||||
actionToMenuItem(pinDocument, context),
|
||||
actionToMenuItem(starDocument, context),
|
||||
actionToMenuItem(unstarDocument, context),
|
||||
actionToMenuItem(subscribeDocument, context),
|
||||
actionToMenuItem(unsubscribeDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
@@ -348,22 +275,9 @@ function DocumentMenu({
|
||||
visible: !!can.createChildDocument,
|
||||
icon: <NewDocumentIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Import document"),
|
||||
visible: can.createChildDocument,
|
||||
onClick: handleImportDocument,
|
||||
icon: <ImportIcon />,
|
||||
},
|
||||
// Templatize document
|
||||
actionToMenuItem(importDocument, context),
|
||||
actionToMenuItem(createTemplate, context),
|
||||
{
|
||||
type: "button",
|
||||
title: t("Duplicate"),
|
||||
onClick: handleDuplicate,
|
||||
visible: !!can.update,
|
||||
icon: <DuplicateIcon />,
|
||||
},
|
||||
actionToMenuItem(duplicateDocument, context),
|
||||
{
|
||||
type: "button",
|
||||
title: t("Unpublish"),
|
||||
@@ -371,36 +285,14 @@ function DocumentMenu({
|
||||
visible: !!can.unpublish,
|
||||
icon: <UnpublishIcon />,
|
||||
},
|
||||
actionToMenuItem(archiveDocument, context),
|
||||
actionToMenuItem(moveDocument, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
{
|
||||
type: "button",
|
||||
title: t("Archive"),
|
||||
onClick: handleArchive,
|
||||
visible: !!can.archive,
|
||||
icon: <ArchiveIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Move")}…`,
|
||||
onClick: () => setShowMoveModal(true),
|
||||
visible: !!can.move,
|
||||
icon: <MoveIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Delete")}…`,
|
||||
dangerous: true,
|
||||
onClick: () => setShowDeleteModal(true),
|
||||
visible: !!can.delete,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Permanently delete")}…`,
|
||||
dangerous: true,
|
||||
onClick: () => setShowPermanentDeleteModal(true),
|
||||
visible: can.permanentDelete,
|
||||
icon: <CrossIcon />,
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
@@ -413,13 +305,7 @@ function DocumentMenu({
|
||||
visible: canViewHistory,
|
||||
icon: <HistoryIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Download"),
|
||||
onClick: document.download,
|
||||
visible: !!can.download,
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
{
|
||||
type: "button",
|
||||
title: t("Print"),
|
||||
@@ -464,54 +350,6 @@ function DocumentMenu({
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
{can.move && (
|
||||
<Modal
|
||||
title={t("Move {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
isOpen={showMoveModal}
|
||||
>
|
||||
<DocumentMove
|
||||
document={document}
|
||||
onRequestClose={() => setShowMoveModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.delete && (
|
||||
<Modal
|
||||
title={t("Delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowDeleteModal(false)}
|
||||
isOpen={showDeleteModal}
|
||||
isCentered
|
||||
>
|
||||
<DocumentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{can.permanentDelete && (
|
||||
<Modal
|
||||
title={t("Permanently delete {{ documentName }}", {
|
||||
documentName: document.noun,
|
||||
})}
|
||||
onRequestClose={() => setShowPermanentDeleteModal(false)}
|
||||
isOpen={showPermanentDeleteModal}
|
||||
isCentered
|
||||
>
|
||||
<DocumentPermanentDelete
|
||||
document={document}
|
||||
onSubmit={() => setShowPermanentDeleteModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ function GroupMenu({ group, onMembers }: Props) {
|
||||
});
|
||||
const [editModalOpen, setEditModalOpen] = React.useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = React.useState(false);
|
||||
const can = usePolicy(group.id);
|
||||
const can = usePolicy(group);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -27,7 +27,7 @@ function NewDocumentMenu() {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
collections.orderedData.reduce<MenuItem[]>((filtered, collection) => {
|
||||
|
||||
@@ -22,7 +22,7 @@ function NewTemplateMenu() {
|
||||
const { t } = useTranslation();
|
||||
const team = useCurrentTeam();
|
||||
const { collections, policies } = useStores();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
const items = React.useMemo(
|
||||
() =>
|
||||
|
||||
@@ -2,11 +2,10 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MenuButton, useMenuState } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import { createAction } from "~/actions";
|
||||
import { navigateToSettings, logout } from "~/actions/definitions/navigation";
|
||||
import { changeTeam } from "~/actions/definitions/teams";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePrevious from "~/hooks/usePrevious";
|
||||
import useSessions from "~/hooks/useSessions";
|
||||
@@ -32,32 +31,11 @@ const OrganizationMenu: React.FC = ({ children }) => {
|
||||
}
|
||||
}, [menu, theme, previousTheme]);
|
||||
|
||||
// NOTE: it's useful to memoize on the team id and session because the action
|
||||
// menu is not cached at all.
|
||||
const actions = React.useMemo(() => {
|
||||
const otherSessions = sessions.filter(
|
||||
(session) => session.teamId !== team.id && session.url !== team.url
|
||||
);
|
||||
|
||||
return [
|
||||
navigateToSettings,
|
||||
separator(),
|
||||
...(otherSessions.length
|
||||
? [
|
||||
createAction({
|
||||
name: t("Switch team"),
|
||||
section: "account",
|
||||
children: otherSessions.map((session) => ({
|
||||
id: session.url,
|
||||
name: session.name,
|
||||
section: "account",
|
||||
icon: <Logo alt={session.name} src={session.logoUrl} />,
|
||||
perform: () => (window.location.href = session.url),
|
||||
})),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
logout,
|
||||
];
|
||||
}, [team.id, team.url, sessions, t]);
|
||||
return [navigateToSettings, separator(), changeTeam, logout];
|
||||
}, [team.id, sessions]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -69,10 +47,4 @@ const OrganizationMenu: React.FC = ({ children }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Logo = styled("img")`
|
||||
border-radius: 2px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default observer(OrganizationMenu);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { trim } from "lodash";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import CollectionsStore from "~/stores/CollectionsStore";
|
||||
import Document from "~/models/Document";
|
||||
@@ -39,7 +40,7 @@ export default class Collection extends ParanoidModel {
|
||||
|
||||
@Field
|
||||
@observable
|
||||
permission: "read" | "read_write" | void;
|
||||
permission: CollectionPermission | void;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
@@ -101,8 +102,14 @@ export default class Collection extends ParanoidModel {
|
||||
return sortNavigationNodes(this.documents, this.sort);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the document identified by the given id in the collection in memory.
|
||||
* Does not update the document in the database.
|
||||
*
|
||||
* @param document The document properties stored in the collection
|
||||
*/
|
||||
@action
|
||||
updateDocument(document: Document) {
|
||||
updateDocument(document: Pick<Document, "id" | "title" | "url">) {
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
nodes.forEach((node) => {
|
||||
if (node.id === document.id) {
|
||||
@@ -116,6 +123,27 @@ export default class Collection extends ParanoidModel {
|
||||
travelNodes(this.documents);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the document identified by the given id from the collection in
|
||||
* memory. Does not remove the document from the database.
|
||||
*
|
||||
* @param documentId The id of the document to remove.
|
||||
*/
|
||||
@action
|
||||
removeDocument(documentId: string) {
|
||||
this.documents = this.documents.filter(function f(node): boolean {
|
||||
if (node.id === documentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
node.children = node.children.filter(f);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
updateIndex(index: string) {
|
||||
this.index = index;
|
||||
@@ -188,7 +216,7 @@ export default class Collection extends ParanoidModel {
|
||||
};
|
||||
|
||||
export = () => {
|
||||
return client.get("/collections.export", {
|
||||
return client.post("/collections.export", {
|
||||
id: this.id,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from "mobx";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import BaseModel from "./BaseModel";
|
||||
|
||||
class CollectionGroupMembership extends BaseModel {
|
||||
@@ -8,16 +9,11 @@ class CollectionGroupMembership extends BaseModel {
|
||||
|
||||
collectionId: string;
|
||||
|
||||
permission: string;
|
||||
permission: CollectionPermission;
|
||||
|
||||
@computed
|
||||
get isEditor(): boolean {
|
||||
return this.permission === "read_write";
|
||||
}
|
||||
|
||||
@computed
|
||||
get isMaintainer(): boolean {
|
||||
return this.permission === "maintainer";
|
||||
return this.permission === CollectionPermission.ReadWrite;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-7
@@ -155,6 +155,19 @@ export default class Document extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether there is a subscription for this document in the store.
|
||||
* Does not consider remote state.
|
||||
*
|
||||
* @returns True if there is a subscription, false otherwise.
|
||||
*/
|
||||
@computed
|
||||
get isSubscribed(): boolean {
|
||||
return !!this.store.rootStore.subscriptions.orderedData.find(
|
||||
(subscription) => subscription.documentId === this.id
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get isArchived(): boolean {
|
||||
return !!this.archivedAt;
|
||||
@@ -255,15 +268,15 @@ export default class Document extends ParanoidModel {
|
||||
};
|
||||
|
||||
@action
|
||||
pin = async (collectionId?: string) => {
|
||||
await this.store.rootStore.pins.create({
|
||||
pin = (collectionId?: string) => {
|
||||
return this.store.rootStore.pins.create({
|
||||
documentId: this.id,
|
||||
...(collectionId ? { collectionId } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
@action
|
||||
unpin = async (collectionId?: string) => {
|
||||
unpin = (collectionId?: string) => {
|
||||
const pin = this.store.rootStore.pins.orderedData.find(
|
||||
(pin) =>
|
||||
pin.documentId === this.id &&
|
||||
@@ -271,19 +284,39 @@ export default class Document extends ParanoidModel {
|
||||
(!collectionId && !pin.collectionId))
|
||||
);
|
||||
|
||||
await pin?.delete();
|
||||
return pin?.delete();
|
||||
};
|
||||
|
||||
@action
|
||||
star = async () => {
|
||||
star = () => {
|
||||
return this.store.star(this);
|
||||
};
|
||||
|
||||
@action
|
||||
unstar = async () => {
|
||||
unstar = () => {
|
||||
return this.store.unstar(this);
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribes the current user to this document.
|
||||
*
|
||||
* @returns A promise that resolves when the subscription is created.
|
||||
*/
|
||||
@action
|
||||
subscribe = () => {
|
||||
return this.store.subscribe(this);
|
||||
};
|
||||
|
||||
/**
|
||||
* Unsubscribes the current user to this document.
|
||||
*
|
||||
* @returns A promise that resolves when the subscription is destroyed.
|
||||
*/
|
||||
@action
|
||||
unsubscribe = (userId: string) => {
|
||||
return this.store.unsubscribe(userId, this);
|
||||
};
|
||||
|
||||
@action
|
||||
view = () => {
|
||||
// we don't record views for documents in the trash
|
||||
@@ -304,7 +337,7 @@ export default class Document extends ParanoidModel {
|
||||
};
|
||||
|
||||
@action
|
||||
templatize = async () => {
|
||||
templatize = () => {
|
||||
return this.store.templatize(this.id);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { observable } from "mobx";
|
||||
import type { IntegrationSettings } from "@shared/types";
|
||||
import BaseModel from "~/models/BaseModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
type Settings = {
|
||||
url: string;
|
||||
channel: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
class Integration extends BaseModel {
|
||||
class Integration<T = unknown> extends BaseModel {
|
||||
id: string;
|
||||
|
||||
type: string;
|
||||
@@ -21,7 +16,7 @@ class Integration extends BaseModel {
|
||||
@observable
|
||||
events: string[];
|
||||
|
||||
settings: Settings;
|
||||
settings: IntegrationSettings<T>;
|
||||
}
|
||||
|
||||
export default Integration;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { computed } from "mobx";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import BaseModel from "./BaseModel";
|
||||
|
||||
class Membership extends BaseModel {
|
||||
@@ -8,16 +9,11 @@ class Membership extends BaseModel {
|
||||
|
||||
collectionId: string;
|
||||
|
||||
permission: string;
|
||||
permission: CollectionPermission;
|
||||
|
||||
@computed
|
||||
get isEditor(): boolean {
|
||||
return this.permission === "read_write";
|
||||
}
|
||||
|
||||
@computed
|
||||
get isMaintainer(): boolean {
|
||||
return this.permission === "maintainer";
|
||||
return this.permission === CollectionPermission.ReadWrite;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { observable } from "mobx";
|
||||
import BaseModel from "./BaseModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
/**
|
||||
* A subscription represents a request for a user to receive notifications for
|
||||
* a document.
|
||||
*/
|
||||
class Subscription extends BaseModel {
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/** The user subscribing */
|
||||
userId: string;
|
||||
|
||||
/** The document being subscribed to */
|
||||
documentId: string;
|
||||
|
||||
/** The event being subscribed to */
|
||||
@Field
|
||||
@observable
|
||||
event: string;
|
||||
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default Subscription;
|
||||
@@ -11,7 +11,7 @@ import Layout from "~/components/AuthenticatedLayout";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import SocketProvider from "~/components/SocketProvider";
|
||||
import WebsocketProvider from "~/components/WebsocketProvider";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
||||
@@ -64,10 +64,10 @@ const RedirectDocument = ({
|
||||
|
||||
function AuthenticatedRoutes() {
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<SocketProvider>
|
||||
<WebsocketProvider>
|
||||
<Layout>
|
||||
<React.Suspense
|
||||
fallback={
|
||||
@@ -116,7 +116,7 @@ function AuthenticatedRoutes() {
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
</SocketProvider>
|
||||
</WebsocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ type Props = {
|
||||
|
||||
function Actions({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
|
||||
function EmptyCollection({ collection }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(collection.id);
|
||||
const can = usePolicy(collection);
|
||||
const collectionName = collection ? collection.name : "";
|
||||
|
||||
const [
|
||||
|
||||
@@ -25,7 +25,9 @@ function CollectionExport({ collection, onSubmit }: Props) {
|
||||
|
||||
setIsLoading(false);
|
||||
showToast(
|
||||
t("Export started, you will receive an email when it’s complete.")
|
||||
t(
|
||||
"Export started. If you have notifications enabled, you will receive an email when it's complete."
|
||||
)
|
||||
);
|
||||
onSubmit();
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||
import { randomElement } from "@shared/random";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
@@ -38,7 +39,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
sharing = true;
|
||||
|
||||
@observable
|
||||
permission = "read_write";
|
||||
permission = CollectionPermission.ReadWrite;
|
||||
|
||||
@observable
|
||||
isSaving: boolean;
|
||||
@@ -100,8 +101,8 @@ class CollectionNew extends React.Component<Props> {
|
||||
this.hasOpenedIconPicker = true;
|
||||
};
|
||||
|
||||
handlePermissionChange = (newPermission: string) => {
|
||||
this.permission = newPermission;
|
||||
handlePermissionChange = (permission: CollectionPermission) => {
|
||||
this.permission = permission;
|
||||
};
|
||||
|
||||
handleSharingChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -59,7 +59,6 @@ class AddGroupsToCollection extends React.Component<Props> {
|
||||
this.props.collectionGroupMemberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
groupId: group.id,
|
||||
permission: "read_write",
|
||||
});
|
||||
this.props.toasts.showToast(
|
||||
t("{{ groupName }} was added to the collection", {
|
||||
|
||||
@@ -57,7 +57,6 @@ class AddPeopleToCollection extends React.Component<Props> {
|
||||
this.props.memberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
userId: user.id,
|
||||
permission: "read_write",
|
||||
});
|
||||
this.props.toasts.showToast(
|
||||
t("{{ userName }} was added to the collection", {
|
||||
@@ -110,7 +109,9 @@ class AddPeopleToCollection extends React.Component<Props> {
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInCollection(collection.id, this.query)}
|
||||
items={users
|
||||
.notInCollection(collection.id, this.query)
|
||||
.filter((member) => member.id !== user.id)}
|
||||
fetch={this.query ? undefined : users.fetchPage}
|
||||
renderItem={(item: User) => (
|
||||
<MemberListItem
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
|
||||
import Group from "~/models/Group";
|
||||
import GroupListItem from "~/components/GroupListItem";
|
||||
@@ -10,7 +11,7 @@ import CollectionGroupMemberMenu from "~/menus/CollectionGroupMemberMenu";
|
||||
type Props = {
|
||||
group: Group;
|
||||
collectionGroupMembership: CollectionGroupMembership | null | undefined;
|
||||
onUpdate: (permission: string) => void;
|
||||
onUpdate: (permission: CollectionPermission) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
@@ -21,19 +22,6 @@ const CollectionGroupMemberListItem = ({
|
||||
onRemove,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const PERMISSIONS = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("View only"),
|
||||
value: "read",
|
||||
},
|
||||
{
|
||||
label: t("View and edit"),
|
||||
value: "read_write",
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<GroupListItem
|
||||
@@ -43,7 +31,16 @@ const CollectionGroupMemberListItem = ({
|
||||
<>
|
||||
<Select
|
||||
label={t("Permissions")}
|
||||
options={PERMISSIONS}
|
||||
options={[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("View and edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
]}
|
||||
value={
|
||||
collectionGroupMembership
|
||||
? collectionGroupMembership.permission
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Membership from "~/models/Membership";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -19,7 +20,7 @@ type Props = {
|
||||
canEdit: boolean;
|
||||
onAdd?: () => void;
|
||||
onRemove?: () => void;
|
||||
onUpdate?: (permission: string) => void;
|
||||
onUpdate?: (permission: CollectionPermission) => void;
|
||||
};
|
||||
|
||||
const MemberListItem = ({
|
||||
@@ -31,19 +32,6 @@ const MemberListItem = ({
|
||||
canEdit,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const PERMISSIONS = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
label: t("View only"),
|
||||
value: "read",
|
||||
},
|
||||
{
|
||||
label: t("View and edit"),
|
||||
value: "read_write",
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
@@ -67,7 +55,16 @@ const MemberListItem = ({
|
||||
{onUpdate && (
|
||||
<Select
|
||||
label={t("Permissions")}
|
||||
options={PERMISSIONS}
|
||||
options={[
|
||||
{
|
||||
label: t("View only"),
|
||||
value: CollectionPermission.Read,
|
||||
},
|
||||
{
|
||||
label: t("View and edit"),
|
||||
value: CollectionPermission.ReadWrite,
|
||||
},
|
||||
]}
|
||||
value={membership ? membership.permission : undefined}
|
||||
onChange={onUpdate}
|
||||
disabled={!canEdit}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Group from "~/models/Group";
|
||||
import User from "~/models/User";
|
||||
@@ -151,7 +152,7 @@ function CollectionPermissions({ collection }: Props) {
|
||||
);
|
||||
|
||||
const handleChangePermission = React.useCallback(
|
||||
async (permission: string) => {
|
||||
async (permission: CollectionPermission) => {
|
||||
try {
|
||||
await collection.save({
|
||||
permission,
|
||||
@@ -218,9 +219,10 @@ function CollectionPermissions({ collection }: Props) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{collection.permission === "read" && (
|
||||
{collection.permission === CollectionPermission.ReadWrite && (
|
||||
<Trans
|
||||
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
|
||||
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
|
||||
default."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
@@ -229,10 +231,9 @@ function CollectionPermissions({ collection }: Props) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{collection.permission === "read_write" && (
|
||||
{collection.permission === CollectionPermission.Read && (
|
||||
<Trans
|
||||
defaults="Team members can view and edit documents in the <em>{{ collectionName }}</em> collection by
|
||||
default."
|
||||
defaults="Team members can view documents in the <em>{{ collectionName }}</em> collection by default."
|
||||
values={{
|
||||
collectionName,
|
||||
}}
|
||||
|
||||
@@ -57,15 +57,17 @@ export default function Contents({ headings, isFullWidth }: Props) {
|
||||
<Heading>{t("Contents")}</Heading>
|
||||
{headings.length ? (
|
||||
<List>
|
||||
{headings.map((heading) => (
|
||||
<ListItem
|
||||
key={heading.id}
|
||||
level={heading.level - headingAdjustment}
|
||||
active={activeSlug === heading.id}
|
||||
>
|
||||
<Link href={`#${heading.id}`}>{heading.title}</Link>
|
||||
</ListItem>
|
||||
))}
|
||||
{headings
|
||||
.filter((heading) => heading.level < 4)
|
||||
.map((heading) => (
|
||||
<ListItem
|
||||
key={heading.id}
|
||||
level={heading.level - headingAdjustment}
|
||||
active={activeSlug === heading.id}
|
||||
>
|
||||
<Link href={`#${heading.id}`}>{heading.title}</Link>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Empty>
|
||||
|
||||
@@ -8,6 +8,7 @@ import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { NotFoundError, OfflineError } from "~/utils/errors";
|
||||
import history from "~/utils/history";
|
||||
import { matchDocumentEdit } from "~/utils/routeHelpers";
|
||||
@@ -41,7 +42,7 @@ type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
|
||||
};
|
||||
|
||||
function DataLoader({ match, children }: Props) {
|
||||
const { ui, shares, documents, auth, revisions } = useStores();
|
||||
const { ui, shares, documents, auth, revisions, subscriptions } = useStores();
|
||||
const { team } = auth;
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
const { revisionId, shareId, documentSlug } = match.params;
|
||||
@@ -57,7 +58,7 @@ function DataLoader({ match, children }: Props) {
|
||||
: undefined;
|
||||
const isEditRoute = match.path === matchDocumentEdit;
|
||||
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
|
||||
const can = usePolicy(document ? document.id : "");
|
||||
const can = usePolicy(document?.id);
|
||||
const location = useLocation<LocationState>();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -86,6 +87,22 @@ function DataLoader({ match, children }: Props) {
|
||||
fetchRevision();
|
||||
}, [revisions, revisionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchSubscription() {
|
||||
if (document?.id) {
|
||||
try {
|
||||
await subscriptions.fetchPage({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
});
|
||||
} catch (err) {
|
||||
Logger.error("Failed to fetch subscriptions", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchSubscription();
|
||||
}, [document?.id, subscriptions]);
|
||||
|
||||
const onCreateLink = React.useCallback(
|
||||
async (title: string) => {
|
||||
if (!document) {
|
||||
|
||||
@@ -100,7 +100,7 @@ function DocumentHeader({
|
||||
}, [onSave]);
|
||||
|
||||
const { isDeleted, isTemplate } = document;
|
||||
const can = usePolicy(document.id);
|
||||
const can = usePolicy(document?.id);
|
||||
const canToggleEmbeds = team?.documentEmbeds;
|
||||
const canEdit = can.update && !isEditing;
|
||||
const toc = (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Location } from "history";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CenteredContent from "~/components/CenteredContent";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
@@ -11,12 +10,9 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function Loading({ location }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Container column auto>
|
||||
<PageTitle
|
||||
title={location.state ? location.state.title : t("Untitled")}
|
||||
/>
|
||||
{location.state?.title && <PageTitle title={location.state.title} />}
|
||||
<CenteredContent>
|
||||
<PlaceholderDocument />
|
||||
</CenteredContent>
|
||||
|
||||
@@ -139,6 +139,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
});
|
||||
|
||||
if (debug) {
|
||||
provider.on("close", (ev: MessageEvent) =>
|
||||
Logger.debug("collaboration", "close", ev)
|
||||
);
|
||||
provider.on("message", (ev: MessageEvent) =>
|
||||
Logger.debug("collaboration", "incoming", {
|
||||
message: ev.message,
|
||||
|
||||
@@ -45,7 +45,7 @@ function SharePopover({
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
const buttonRef = React.useRef<HTMLButtonElement>(null);
|
||||
const can = usePolicy(share ? share.id : "");
|
||||
const documentAbilities = usePolicy(document.id);
|
||||
const documentAbilities = usePolicy(document);
|
||||
const canPublish =
|
||||
can.update &&
|
||||
!document.isTemplate &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { USER_PRESENCE_INTERVAL } from "@shared/constants";
|
||||
import { SocketContext } from "~/components/SocketProvider";
|
||||
import { WebsocketContext } from "~/components/WebsocketProvider";
|
||||
|
||||
type Props = {
|
||||
documentId: string;
|
||||
@@ -8,9 +8,9 @@ type Props = {
|
||||
};
|
||||
|
||||
export default class SocketPresence extends React.Component<Props> {
|
||||
static contextType = SocketContext;
|
||||
static contextType = WebsocketContext;
|
||||
|
||||
previousContext: typeof SocketContext;
|
||||
previousContext: typeof WebsocketContext;
|
||||
|
||||
editingInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -38,8 +39,8 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const prevCollection = collections.get(item.collectionId);
|
||||
const accessMapping = {
|
||||
read_write: t("view and edit access"),
|
||||
read: t("view only access"),
|
||||
[CollectionPermission.ReadWrite]: t("view and edit access"),
|
||||
[CollectionPermission.Read]: t("view only access"),
|
||||
null: t("no access"),
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
|
||||
try {
|
||||
await group.save({
|
||||
name: name,
|
||||
name,
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
|
||||
@@ -26,7 +26,7 @@ function GroupMembers({ group }: Props) {
|
||||
const { users, groupMemberships } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(group.id);
|
||||
const can = usePolicy(group);
|
||||
|
||||
const handleAddModal = (state: boolean) => {
|
||||
setAddModalOpen(state);
|
||||
|
||||
@@ -29,7 +29,7 @@ function GroupNew({ onSubmit }: Props) {
|
||||
|
||||
const group = new Group(
|
||||
{
|
||||
name: name,
|
||||
name,
|
||||
},
|
||||
groups
|
||||
);
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ function Home() {
|
||||
pins.fetchPage();
|
||||
}, [pins]);
|
||||
|
||||
const canManageTeam = usePolicy(team.id).manage;
|
||||
const canManageTeam = usePolicy(team).manage;
|
||||
|
||||
return (
|
||||
<Scene
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { Role } from "@shared/types";
|
||||
import { UserValidation } from "@shared/validations";
|
||||
import Button from "~/components/Button";
|
||||
import CopyToClipboard from "~/components/CopyToClipboard";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -19,8 +20,6 @@ import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
const MAX_INVITES = 20;
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
@@ -57,7 +56,7 @@ function Invite({ onSubmit }: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const predictedDomain = user.email.split("@")[1];
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
@@ -97,10 +96,10 @@ function Invite({ onSubmit }: Props) {
|
||||
}, []);
|
||||
|
||||
const handleAdd = React.useCallback(() => {
|
||||
if (invites.length >= MAX_INVITES) {
|
||||
if (invites.length >= UserValidation.maxInvitesPerRequest) {
|
||||
showToast(
|
||||
t("Sorry, you can only send {{MAX_INVITES}} invites at a time", {
|
||||
MAX_INVITES,
|
||||
MAX_INVITES: UserValidation.maxInvitesPerRequest,
|
||||
}),
|
||||
{
|
||||
type: "warning",
|
||||
@@ -241,7 +240,7 @@ function Invite({ onSubmit }: Props) {
|
||||
))}
|
||||
|
||||
<Flex justify="space-between">
|
||||
{invites.length <= MAX_INVITES ? (
|
||||
{invites.length <= UserValidation.maxInvitesPerRequest ? (
|
||||
<Button type="button" onClick={handleAdd} neutral>
|
||||
<Trans>Add another</Trans>…
|
||||
</Button>
|
||||
|
||||
@@ -243,7 +243,7 @@ const CheckEmailIcon = styled(EmailIcon)`
|
||||
|
||||
const Background = styled(Fade)`
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
background: ${(props) => props.theme.background};
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { head } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { BuildingBlocksIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import { ReactHookWrappedInput as Input } from "~/components/Input";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type FormData = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
const SERVICE_NAME = "diagrams";
|
||||
|
||||
function Drawio() {
|
||||
const { integrations } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
React.useEffect(() => {
|
||||
integrations.fetchPage({
|
||||
service: SERVICE_NAME,
|
||||
type: IntegrationType.Embed,
|
||||
});
|
||||
}, [integrations]);
|
||||
|
||||
const integration = head(integrations.orderedData) as
|
||||
| Integration<IntegrationType.Embed>
|
||||
| undefined;
|
||||
|
||||
const { register, handleSubmit: formHandleSubmit, formState } = useForm<
|
||||
FormData
|
||||
>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
url: integration?.settings.url,
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
await integrations.save({
|
||||
id: integration?.id,
|
||||
type: IntegrationType.Embed,
|
||||
service: SERVICE_NAME,
|
||||
settings: {
|
||||
url: data.url,
|
||||
},
|
||||
});
|
||||
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[integrations, integration, t, showToast]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title="Draw.io" icon={<BuildingBlocksIcon color="currentColor" />}>
|
||||
<Heading>Draw.io</Heading>
|
||||
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Add your self-hosted draw.io installation url here to enable automatic
|
||||
embedding of diagrams within documents.
|
||||
</Trans>
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<p>
|
||||
<Input
|
||||
label={t("Draw.io deployment")}
|
||||
placeholder={"https://app.diagrams.net/"}
|
||||
pattern="https?://.*"
|
||||
{...register("url", {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
<Button type="submit" disabled={formState.isSubmitting}>
|
||||
{formState.isSubmitting ? `${t("Saving")}…` : t("Save")}
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
</Text>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Drawio);
|
||||
@@ -56,7 +56,7 @@ function Export() {
|
||||
<Heading>{t("Export")}</Heading>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – we will email a link to <em>{{ userEmail }}</em> when it’s complete."
|
||||
defaults="A full export might take some time, consider exporting a single document or collection. The exported data is a zip of your documents in Markdown format. You may leave this page once the export has started – if you have notifications enabled, we will email a link to <em>{{ userEmail }}</em> when it’s complete."
|
||||
values={{
|
||||
userEmail: user.email,
|
||||
}}
|
||||
|
||||
@@ -24,7 +24,7 @@ function Groups() {
|
||||
const { t } = useTranslation();
|
||||
const { groups } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const [
|
||||
newGroupModalOpen,
|
||||
handleNewGroupModalOpen,
|
||||
|
||||
@@ -40,7 +40,7 @@ function Members() {
|
||||
const [data, setData] = React.useState<User[]>([]);
|
||||
const [totalPages, setTotalPages] = React.useState(0);
|
||||
const [userIds, setUserIds] = React.useState<string[]>([]);
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const query = params.get("query") || "";
|
||||
const filter = params.get("filter") || "";
|
||||
const sort = params.get("sort") || "name";
|
||||
|
||||
@@ -51,6 +51,13 @@ function Notifications() {
|
||||
"Receive a notification when someone you invited creates an account"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: "emails.export_completed",
|
||||
title: t("Export completed"),
|
||||
description: t(
|
||||
"Receive a notification when an export you requested has been completed"
|
||||
),
|
||||
},
|
||||
{
|
||||
visible: isCloudHosted,
|
||||
event: "emails.onboarding",
|
||||
|
||||
@@ -21,7 +21,7 @@ function Shares() {
|
||||
const { t } = useTranslation();
|
||||
const { shares, auth } = useStores();
|
||||
const canShareDocuments = auth.team && auth.team.sharing;
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [data, setData] = React.useState<Share[]>([]);
|
||||
const [totalPages, setTotalPages] = React.useState(0);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
@@ -124,7 +125,9 @@ function Slack() {
|
||||
<SlackListItem
|
||||
key={integration.id}
|
||||
collection={collection}
|
||||
integration={integration}
|
||||
integration={
|
||||
integration as Integration<IntegrationType.Post>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function Tokens() {
|
||||
const { t } = useTranslation();
|
||||
const { apiKeys } = useStores();
|
||||
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
|
||||
@@ -23,7 +23,7 @@ function Webhooks() {
|
||||
const { t } = useTranslation();
|
||||
const { webhookSubscriptions } = useStores();
|
||||
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
import styled from "styled-components";
|
||||
import { IntegrationType } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import Integration from "~/models/Integration";
|
||||
import Button from "~/components/Button";
|
||||
@@ -17,7 +18,7 @@ import Text from "~/components/Text";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
integration: Integration;
|
||||
integration: Integration<IntegrationType.Post>;
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
|
||||
@@ -31,8 +31,6 @@ const WEBHOOK_EVENTS = {
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.restore",
|
||||
"documents.star",
|
||||
"documents.unstar",
|
||||
"documents.move",
|
||||
"documents.update",
|
||||
"documents.update.delayed",
|
||||
|
||||
@@ -21,7 +21,7 @@ function Templates(props: RouteComponentProps<{ sort: string }>) {
|
||||
const team = useCurrentTeam();
|
||||
const { fetchTemplates, templates, templatesAlphabetical } = documents;
|
||||
const { sort } = props.match.params;
|
||||
const can = usePolicy(team.id);
|
||||
const can = usePolicy(team);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
|
||||
@@ -260,12 +260,6 @@ export default class AuthStore {
|
||||
|
||||
@action
|
||||
logout = async (savePath = false) => {
|
||||
if (!this.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.post(`/auth.delete`);
|
||||
|
||||
// if this logout was forced from an authenticated route then
|
||||
// save the current path so we can go back there once signed in
|
||||
if (savePath) {
|
||||
@@ -276,10 +270,19 @@ export default class AuthStore {
|
||||
}
|
||||
}
|
||||
|
||||
// If there is no auth token stored there is nothing else to do
|
||||
if (!this.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
// invalidate authentication token on server
|
||||
client.post(`/auth.delete`);
|
||||
|
||||
// remove authentication token itself
|
||||
removeCookie("accessToken", {
|
||||
path: "/",
|
||||
});
|
||||
|
||||
// remove session record on apex cookie
|
||||
const team = this.team;
|
||||
|
||||
|
||||
@@ -5,12 +5,10 @@ import { Class } from "utility-types";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import BaseModel from "~/models/BaseModel";
|
||||
import Policy from "~/models/Policy";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { PaginationParams, PartialWithId } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
|
||||
type PartialWithId<T> = Partial<T> & { id: string };
|
||||
|
||||
export enum RPCAction {
|
||||
Info = "info",
|
||||
List = "list",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import invariant from "invariant";
|
||||
import { action, runInAction } from "mobx";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import CollectionGroupMembership from "~/models/CollectionGroupMembership";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -45,7 +46,7 @@ export default class CollectionGroupMembershipsStore extends BaseStore<
|
||||
}: {
|
||||
collectionId: string;
|
||||
groupId: string;
|
||||
permission: string;
|
||||
permission?: CollectionPermission;
|
||||
}) {
|
||||
const res = await client.post("/collections.add_group", {
|
||||
id: collectionId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import invariant from "invariant";
|
||||
import { concat, find, last } from "lodash";
|
||||
import { computed, action } from "mobx";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Collection from "~/models/Collection";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -171,8 +172,10 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
|
||||
@computed
|
||||
get publicCollections() {
|
||||
return this.orderedData.filter((collection) =>
|
||||
["read", "read_write"].includes(collection.permission || "")
|
||||
return this.orderedData.filter(
|
||||
(collection) =>
|
||||
collection.permission &&
|
||||
Object.values(CollectionPermission).includes(collection.permission)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -381,7 +381,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
|
||||
@action
|
||||
searchTitles = async (query: string) => {
|
||||
const res = await client.get("/documents.search_titles", {
|
||||
const res = await client.post("/documents.search_titles", {
|
||||
query,
|
||||
});
|
||||
invariant(res?.data, "Search response should be available");
|
||||
@@ -397,7 +397,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
options: SearchParams
|
||||
): Promise<SearchResult[]> => {
|
||||
const compactedOptions = omitBy(options, (o) => !o);
|
||||
const res = await client.get("/documents.search", {
|
||||
const res = await client.post("/documents.search", {
|
||||
...compactedOptions,
|
||||
query,
|
||||
});
|
||||
@@ -532,7 +532,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
id: documentId,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
index: index,
|
||||
index,
|
||||
});
|
||||
invariant(res?.data, "Data not available");
|
||||
res.data.documents.forEach(this.add);
|
||||
@@ -740,20 +740,37 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
}
|
||||
};
|
||||
|
||||
star = async (document: Document) => {
|
||||
await this.rootStore.stars.create({
|
||||
star = (document: Document) => {
|
||||
return this.rootStore.stars.create({
|
||||
documentId: document.id,
|
||||
});
|
||||
};
|
||||
|
||||
unstar = async (document: Document) => {
|
||||
unstar = (document: Document) => {
|
||||
const star = this.rootStore.stars.orderedData.find(
|
||||
(star) => star.documentId === document.id
|
||||
);
|
||||
await star?.delete();
|
||||
return star?.delete();
|
||||
};
|
||||
|
||||
getByUrl = (url = ""): Document | null | undefined => {
|
||||
subscribe = (document: Document) => {
|
||||
return this.rootStore.subscriptions.create({
|
||||
documentId: document.id,
|
||||
event: "documents.update",
|
||||
});
|
||||
};
|
||||
|
||||
unsubscribe = (userId: string, document: Document) => {
|
||||
const subscription = this.rootStore.subscriptions.orderedData.find(
|
||||
(subscription) =>
|
||||
subscription.documentId === document.id &&
|
||||
subscription.userId === userId
|
||||
);
|
||||
|
||||
return subscription?.delete();
|
||||
};
|
||||
|
||||
getByUrl = (url = ""): Document | undefined => {
|
||||
return find(this.orderedData, (doc) => url.endsWith(doc.urlId));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import invariant from "invariant";
|
||||
import { action, runInAction } from "mobx";
|
||||
import { CollectionPermission } from "@shared/types";
|
||||
import Membership from "~/models/Membership";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
@@ -43,7 +44,7 @@ export default class MembershipsStore extends BaseStore<Membership> {
|
||||
}: {
|
||||
collectionId: string;
|
||||
userId: string;
|
||||
permission: string;
|
||||
permission?: CollectionPermission;
|
||||
}) {
|
||||
const res = await client.post("/collections.add_user", {
|
||||
id: collectionId,
|
||||
|
||||
@@ -18,6 +18,7 @@ import RevisionsStore from "./RevisionsStore";
|
||||
import SearchesStore from "./SearchesStore";
|
||||
import SharesStore from "./SharesStore";
|
||||
import StarsStore from "./StarsStore";
|
||||
import SubscriptionsStore from "./SubscriptionsStore";
|
||||
import ToastsStore from "./ToastsStore";
|
||||
import UiStore from "./UiStore";
|
||||
import UsersStore from "./UsersStore";
|
||||
@@ -45,6 +46,7 @@ export default class RootStore {
|
||||
shares: SharesStore;
|
||||
ui: UiStore;
|
||||
stars: StarsStore;
|
||||
subscriptions: SubscriptionsStore;
|
||||
users: UsersStore;
|
||||
views: ViewsStore;
|
||||
toasts: ToastsStore;
|
||||
@@ -72,6 +74,7 @@ export default class RootStore {
|
||||
this.searches = new SearchesStore(this);
|
||||
this.shares = new SharesStore(this);
|
||||
this.stars = new StarsStore(this);
|
||||
this.subscriptions = new SubscriptionsStore(this);
|
||||
this.ui = new UiStore();
|
||||
this.users = new UsersStore(this);
|
||||
this.views = new ViewsStore(this);
|
||||
@@ -99,6 +102,7 @@ export default class RootStore {
|
||||
this.searches.clear();
|
||||
this.shares.clear();
|
||||
this.stars.clear();
|
||||
this.subscriptions.clear();
|
||||
this.fileOperations.clear();
|
||||
// this.ui omitted to keep ui settings between sessions
|
||||
this.users.clear();
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import Subscription from "~/models/Subscription";
|
||||
import BaseStore, { RPCAction } from "./BaseStore";
|
||||
import RootStore from "./RootStore";
|
||||
|
||||
export default class SubscriptionsStore extends BaseStore<Subscription> {
|
||||
actions = [RPCAction.List, RPCAction.Create, RPCAction.Delete];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Subscription);
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -12,9 +12,11 @@ export default createGlobalStyle`
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
|
||||
body,
|
||||
@@ -37,6 +39,13 @@ export default createGlobalStyle`
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
@media (min-width: ${breakpoints.tablet}px) {
|
||||
html,
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: ${breakpoints.tablet}px) and (display-mode: standalone) {
|
||||
body:after {
|
||||
content: "";
|
||||
|
||||
+38
-1
@@ -1,7 +1,12 @@
|
||||
import { Location, LocationDescriptor } from "history";
|
||||
import { TFunction } from "react-i18next";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
import Document from "./models/Document";
|
||||
import FileOperation from "./models/FileOperation";
|
||||
import Pin from "./models/Pin";
|
||||
import Star from "./models/Star";
|
||||
|
||||
export type PartialWithId<T> = Partial<T> & { id: string };
|
||||
|
||||
export type MenuItemButton = {
|
||||
type: "button";
|
||||
@@ -88,6 +93,7 @@ export type Action = {
|
||||
section: ((context: ActionContext) => string) | string;
|
||||
shortcut?: string[];
|
||||
keywords?: string;
|
||||
dangerous?: boolean;
|
||||
iconInContextMenu?: boolean;
|
||||
icon?: React.ReactElement | React.FC;
|
||||
placeholder?: ((context: ActionContext) => string) | string;
|
||||
@@ -177,3 +183,34 @@ export type ToastOptions = {
|
||||
onClick: React.MouseEventHandler<HTMLSpanElement>;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebsocketEntityDeletedEvent = {
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
export type WebsocketEntitiesEvent = {
|
||||
documentIds: { id: string; updatedAt?: string }[];
|
||||
collectionIds: { id: string; updatedAt?: string }[];
|
||||
groupIds: { id: string; updatedAt?: string }[];
|
||||
teamIds: string[];
|
||||
event: string;
|
||||
};
|
||||
|
||||
export type WebsocketCollectionUserEvent = {
|
||||
collectionId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type WebsocketCollectionUpdateIndexEvent = {
|
||||
collectionId: string;
|
||||
index: string;
|
||||
};
|
||||
|
||||
export type WebsocketEvent =
|
||||
| PartialWithId<Pin>
|
||||
| PartialWithId<Star>
|
||||
| PartialWithId<FileOperation>
|
||||
| WebsocketCollectionUserEvent
|
||||
| WebsocketCollectionUpdateIndexEvent
|
||||
| WebsocketEntityDeletedEvent
|
||||
| WebsocketEntitiesEvent;
|
||||
|
||||
+16
-1
@@ -5,6 +5,7 @@ import queryString from "query-string";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import stores from "~/stores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Logger from "./Logger";
|
||||
import download from "./download";
|
||||
import {
|
||||
AuthorizationError,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
NetworkError,
|
||||
NotFoundError,
|
||||
OfflineError,
|
||||
RateLimitExceededError,
|
||||
RequestError,
|
||||
ServiceUnavailableError,
|
||||
UpdateRequiredError,
|
||||
@@ -181,7 +183,20 @@ class ApiClient {
|
||||
throw new ServiceUnavailableError(error.message);
|
||||
}
|
||||
|
||||
throw new RequestError(`Error ${response.status}: ${error.message}`);
|
||||
if (response.status === 429) {
|
||||
throw new RateLimitExceededError(
|
||||
`Too many requests, try again in a minute.`
|
||||
);
|
||||
}
|
||||
|
||||
const err = new RequestError(`Error ${response.status}`);
|
||||
Logger.error("Request failed", err, {
|
||||
...error,
|
||||
url: urlToFetch,
|
||||
});
|
||||
|
||||
// Still need to throw to trigger retry
|
||||
throw err;
|
||||
};
|
||||
|
||||
get = (
|
||||
|
||||
@@ -20,7 +20,7 @@ let supportsPassive = false;
|
||||
|
||||
try {
|
||||
const opts = Object.defineProperty({}, "passive", {
|
||||
get: function () {
|
||||
get() {
|
||||
supportsPassive = true;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -12,6 +12,8 @@ export class OfflineError extends ExtendableError {}
|
||||
|
||||
export class ServiceUnavailableError extends ExtendableError {}
|
||||
|
||||
export class RateLimitExceededError extends ExtendableError {}
|
||||
|
||||
export class RequestError extends ExtendableError {}
|
||||
|
||||
export class UpdateRequiredError extends ExtendableError {}
|
||||
|
||||
@@ -22,6 +22,7 @@ export function initSentry(history: History) {
|
||||
"NetworkError",
|
||||
"NotFoundError",
|
||||
"OfflineError",
|
||||
"RateLimitExceededError",
|
||||
"ServiceUnavailableError",
|
||||
"UpdateRequiredError",
|
||||
"ChunkLoadError",
|
||||
|
||||
+29
-21
@@ -20,14 +20,15 @@
|
||||
"heroku-postbuild": "yarn build:webpack && yarn build:server && yarn copy:i18n && yarn db:migrate",
|
||||
"sequelize:migrate": "sequelize db:migrate",
|
||||
"db:create-migration": "sequelize migration:create",
|
||||
"db:create": "sequelize db:create",
|
||||
"db:migrate": "sequelize db:migrate",
|
||||
"db:rollback": "sequelize db:migrate:undo",
|
||||
"db:reset": "sequelize db:drop && sequelize db:create && sequelize db:migrate",
|
||||
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
|
||||
"test": "yarn test:app && yarn test:server",
|
||||
"test:app": "jest --config=app/.jestconfig.json --runInBand --forceExit",
|
||||
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
|
||||
"test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll"
|
||||
"test": "jest --config=.jestconfig.json --forceExit",
|
||||
"test:app": "jest --config=.jestconfig.json --selectProjects app",
|
||||
"test:shared": "jest --config=.jestconfig.json --selectProjects shared-node shared-jsdom",
|
||||
"test:server": "jest --config=.jestconfig.json --selectProjects server"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
@@ -50,8 +51,8 @@
|
||||
"@babel/plugin-transform-regenerator": "^7.10.4",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@babel/preset-react": "^7.16.0",
|
||||
"@bull-board/api": "^4.0.0",
|
||||
"@bull-board/koa": "^4.0.0",
|
||||
"@bull-board/api": "^4.2.2",
|
||||
"@bull-board/koa": "^4.2.2",
|
||||
"@dnd-kit/core": "^4.0.3",
|
||||
"@dnd-kit/modifiers": "^4.0.0",
|
||||
"@dnd-kit/sortable": "^5.1.0",
|
||||
@@ -66,21 +67,21 @@
|
||||
"@sentry/node": "^6.3.1",
|
||||
"@sentry/react": "^6.3.1",
|
||||
"@sentry/tracing": "^6.3.1",
|
||||
"@theo.gravity/datadog-apm": "2.1.0",
|
||||
"@theo.gravity/datadog-apm": "2.2.0",
|
||||
"@tippy.js/react": "^2.2.2",
|
||||
"@tommoor/remove-markdown": "^0.3.2",
|
||||
"@types/mermaid": "^8.2.9",
|
||||
"autotrack": "^2.4.1",
|
||||
"aws-sdk": "^2.1044.0",
|
||||
"aws-sdk": "^2.1189.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-styled-components": "^1.11.1",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"body-scroll-lock": "^4.0.0-beta.0",
|
||||
"bull": "^3.29.0",
|
||||
"bull": "^4.8.5",
|
||||
"cancan": "3.1.0",
|
||||
"chalk": "^4.1.0",
|
||||
"class-validator": "^0.13.2",
|
||||
"compressorjs": "^1.0.7",
|
||||
"compressorjs": "^1.1.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"core-js": "^3.10.2",
|
||||
"crypto-js": "^4.1.1",
|
||||
@@ -105,7 +106,7 @@
|
||||
"i18next-http-backend": "^1.3.2",
|
||||
"immutable": "^4.0.0",
|
||||
"invariant": "^2.2.4",
|
||||
"ioredis": "^4.28.0",
|
||||
"ioredis": "^4.28.5",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
"json-loader": "0.5.4",
|
||||
"jsonwebtoken": "^8.5.0",
|
||||
@@ -123,9 +124,10 @@
|
||||
"koa-send": "5.0.1",
|
||||
"koa-sslify": "2.1.2",
|
||||
"koa-static": "^4.0.1",
|
||||
"koa-useragent": "^4.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mammoth": "^1.4.19",
|
||||
"markdown-it": "^12.3.2",
|
||||
"markdown-it": "^13.0.1",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
"mermaid": "9.1.3",
|
||||
@@ -135,8 +137,8 @@
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"nodemailer": "^6.6.1",
|
||||
"outline-icons": "^1.43.1",
|
||||
"oy-vey": "^0.10.0",
|
||||
"outline-icons": "^1.44.0",
|
||||
"oy-vey": "^0.11.2",
|
||||
"passport": "^0.6.0",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
"passport-oauth2": "^1.6.1",
|
||||
@@ -150,7 +152,7 @@
|
||||
"prosemirror-history": "^1.2.0",
|
||||
"prosemirror-inputrules": "^1.1.3",
|
||||
"prosemirror-keymap": "^1.1.5",
|
||||
"prosemirror-markdown": "^1.5.2",
|
||||
"prosemirror-markdown": "^1.9.3",
|
||||
"prosemirror-model": "1.16.1",
|
||||
"prosemirror-schema-list": "1.1.4",
|
||||
"prosemirror-state": "1.3.4",
|
||||
@@ -161,6 +163,7 @@
|
||||
"query-string": "^7.0.1",
|
||||
"quoted-printable": "^1.0.1",
|
||||
"randomstring": "1.1.5",
|
||||
"rate-limiter-flexible": "^2.3.7",
|
||||
"raw-loader": "^0.5.1",
|
||||
"react": "^17.0.2",
|
||||
"react-avatar-editor": "^11.1.0",
|
||||
@@ -173,7 +176,7 @@
|
||||
"react-hook-form": "^7.31.2",
|
||||
"react-i18next": "^11.16.6",
|
||||
"react-medium-image-zoom": "^3.1.3",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-merge-refs": "^2.0.1",
|
||||
"react-portal": "^4.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-table": "^7.7.0",
|
||||
@@ -219,22 +222,24 @@
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.10.5",
|
||||
"@babel/preset-typescript": "^7.16.0",
|
||||
"@getoutline/jest-runner-serial": "^2.0.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
|
||||
"@relative-ci/agent": "^3.0.0",
|
||||
"@types/body-scroll-lock": "^3.1.0",
|
||||
"@types/bull": "^3.15.5",
|
||||
"@types/bull": "^3.15.9",
|
||||
"@types/crypto-js": "^4.1.0",
|
||||
"@types/datadog-metrics": "^0.6.2",
|
||||
"@types/emoji-regex": "^9.2.0",
|
||||
"@types/enzyme": "^3.10.10",
|
||||
"@types/enzyme-adapter-react-16": "^1.0.6",
|
||||
"@types/express-useragent": "^1.0.2",
|
||||
"@types/formidable": "^2.0.5",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/fuzzy-search": "^2.1.2",
|
||||
"@types/google.analytics": "^0.0.42",
|
||||
"@types/invariant": "^2.2.35",
|
||||
"@types/ioredis": "^4.28.1",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/jest": "^28.1.6",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/koa": "^2.13.4",
|
||||
"@types/koa-compress": "^4.0.3",
|
||||
@@ -244,6 +249,7 @@
|
||||
"@types/koa-router": "^7.4.4",
|
||||
"@types/koa-sslify": "^2.1.0",
|
||||
"@types/koa-static": "^4.0.2",
|
||||
"@types/koa-useragent": "^2.1.2",
|
||||
"@types/markdown-it": "^12.2.3",
|
||||
"@types/markdown-it-container": "^2.0.4",
|
||||
"@types/markdown-it-emoji": "^2.0.2",
|
||||
@@ -277,6 +283,7 @@
|
||||
"@types/react-table": "^7.7.9",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/redis-info": "^3.0.0",
|
||||
"@types/refractor": "^3.0.2",
|
||||
"@types/semver": "^7.3.10",
|
||||
"@types/sequelize": "^4.28.10",
|
||||
@@ -291,7 +298,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.3.1",
|
||||
"@typescript-eslint/parser": "^5.3.1",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^27.4.5",
|
||||
"babel-jest": "^28.1.3",
|
||||
"babel-loader": "^8.1.0",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
|
||||
"babel-plugin-transform-typescript-metadata": "^0.3.2",
|
||||
@@ -313,7 +320,8 @@
|
||||
"html-webpack-plugin": "4.5.1",
|
||||
"husky": "^7.0.4",
|
||||
"i18next-parser": "^6.3.0",
|
||||
"jest-cli": "^27.4.5",
|
||||
"jest-cli": "^28.1.3",
|
||||
"jest-environment-jsdom": "^28.1.3",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"koa-webpack-dev-middleware": "^1.4.5",
|
||||
"koa-webpack-hot-middleware": "^1.0.3",
|
||||
@@ -340,5 +348,5 @@
|
||||
"js-yaml": "^3.14.1",
|
||||
"jpeg-js": "0.4.4"
|
||||
},
|
||||
"version": "0.65.2"
|
||||
"version": "0.66.1"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 533 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"verbose": false,
|
||||
"rootDir": "..",
|
||||
"roots": [
|
||||
"<rootDir>/server",
|
||||
"<rootDir>/shared"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^@server/(.*)$": "<rootDir>/server/$1",
|
||||
"^@shared/(.*)$": "<rootDir>/shared/$1"
|
||||
},
|
||||
"setupFiles": [
|
||||
"<rootDir>/__mocks__/console.js",
|
||||
"./server/test/setup.ts"
|
||||
],
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
IRateLimiterStoreOptions,
|
||||
RateLimiterRedis,
|
||||
} from "rate-limiter-flexible";
|
||||
import env from "@server/env";
|
||||
import Redis from "@server/redis";
|
||||
|
||||
export default class RateLimiter {
|
||||
constructor() {
|
||||
throw Error(`Cannot instantiate class!`);
|
||||
}
|
||||
|
||||
static readonly RATE_LIMITER_REDIS_KEY_PREFIX = "rl";
|
||||
|
||||
static readonly rateLimiterMap = new Map<string, RateLimiterRedis>();
|
||||
|
||||
static readonly defaultRateLimiter = new RateLimiterRedis({
|
||||
storeClient: Redis.defaultClient,
|
||||
points: env.RATE_LIMITER_REQUESTS,
|
||||
duration: env.RATE_LIMITER_DURATION_WINDOW,
|
||||
keyPrefix: this.RATE_LIMITER_REDIS_KEY_PREFIX,
|
||||
});
|
||||
|
||||
static getRateLimiter(path: string): RateLimiterRedis {
|
||||
return this.rateLimiterMap.get(path) || this.defaultRateLimiter;
|
||||
}
|
||||
|
||||
static setRateLimiter(path: string, config: IRateLimiterStoreOptions): void {
|
||||
const rateLimiter = new RateLimiterRedis(config);
|
||||
this.rateLimiterMap.set(path, rateLimiter);
|
||||
}
|
||||
|
||||
static hasRateLimiter(path: string): boolean {
|
||||
return this.rateLimiterMap.has(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-useable configuration for rate limiter middleware.
|
||||
*/
|
||||
export const RateLimiterStrategy = {
|
||||
/** Allows five requests per minute, per IP address */
|
||||
FivePerMinute: {
|
||||
duration: 60,
|
||||
requests: 5,
|
||||
},
|
||||
/** Allows ten requests per minute, per IP address */
|
||||
TenPerMinute: {
|
||||
duration: 60,
|
||||
requests: 10,
|
||||
},
|
||||
/** Allows ten requests per hour, per IP address */
|
||||
TenPerHour: {
|
||||
duration: 3600,
|
||||
requests: 10,
|
||||
},
|
||||
/** Allows five requests per hour, per IP address */
|
||||
FivePerHour: {
|
||||
duration: 3600,
|
||||
requests: 5,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
export default class Queue {
|
||||
done() {
|
||||
//
|
||||
}
|
||||
|
||||
on() {
|
||||
//
|
||||
}
|
||||
|
||||
count() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
getDelayedCount() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
add = function (data: any) {
|
||||
const job = this.createJob(data);
|
||||
|
||||
if (!this.handler) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handler(job, this.done);
|
||||
};
|
||||
|
||||
process = function (handler: any) {
|
||||
if (this.handler) {
|
||||
throw Error("Cannot define a handler more than once per Queue instance");
|
||||
}
|
||||
|
||||
this.handler = handler;
|
||||
};
|
||||
|
||||
createJob = function (data: any) {
|
||||
return {
|
||||
data,
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import { TeamDomain } from "@server/models";
|
||||
import Collection from "@server/models/Collection";
|
||||
import UserAuthentication from "@server/models/UserAuthentication";
|
||||
import { buildUser, buildTeam } from "@server/test/factories";
|
||||
import { flushdb, seed } from "@server/test/support";
|
||||
import { getTestDatabase, seed } from "@server/test/support";
|
||||
import accountProvisioner from "./accountProvisioner";
|
||||
|
||||
beforeEach(() => {
|
||||
return flushdb();
|
||||
});
|
||||
const db = getTestDatabase();
|
||||
|
||||
afterAll(db.disconnect);
|
||||
|
||||
beforeEach(db.flush);
|
||||
|
||||
describe("accountProvisioner", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
@@ -2,11 +2,16 @@ import path from "path";
|
||||
import fs from "fs-extra";
|
||||
import Attachment from "@server/models/Attachment";
|
||||
import { buildUser } from "@server/test/factories";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import { getTestDatabase } from "@server/test/support";
|
||||
import documentImporter from "./documentImporter";
|
||||
|
||||
jest.mock("../utils/s3");
|
||||
beforeEach(() => flushdb());
|
||||
|
||||
const db = getTestDatabase();
|
||||
|
||||
afterAll(db.disconnect);
|
||||
|
||||
beforeEach(db.flush);
|
||||
|
||||
describe("documentImporter", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user