mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
171 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfaed5d9cb | |||
| 5923281edb | |||
| 01bfe2bde7 | |||
| 9c6780adab | |||
| 93f1d4cfc7 | |||
| 21a43dfc5e | |||
| 47fafb5d69 | |||
| 32d76eeb9e | |||
| 99bef2c02b | |||
| 1125412972 | |||
| 2e0d160fcc | |||
| 21e31be517 | |||
| 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 | |||
| 982ab2b48e | |||
| 74d9409cc3 | |||
| 0a6cfe5a6a | |||
| 4a16124a94 | |||
| 294521f162 | |||
| 00481d2bfc | |||
| eace258a86 | |||
| de4b515e64 | |||
| c35c566fef | |||
| d9dc6aa2d7 | |||
| 192802d360 | |||
| cb9773ad85 | |||
| f9d9a82e47 | |||
| 383bac241e | |||
| ea28dc46eb | |||
| 2794057738 | |||
| b7b1f5e1fd | |||
| 8fdd5bf734 | |||
| 086c3ec2d8 | |||
| f370b0296b | |||
| 9b837763e6 | |||
| 3d9a8be867 | |||
| c8caeebdba | |||
| 2c7d5ac3d8 | |||
| 30190866f8 | |||
| 53a08cf307 | |||
| 1c5864deee | |||
| 865e6d048e | |||
| 5e852170f9 | |||
| 71da57773e | |||
| ec35af4bc5 | |||
| 870d9ed41e | |||
| 24170e8684 | |||
| 7ae892fe06 | |||
| 4f537c7578 | |||
| 4bca081faa | |||
| 87b4c9fdba | |||
| 6fe4b1cba1 | |||
| ef2abf824e | |||
| 4b4b0f7037 | |||
| 80d50e3d88 | |||
| ba264974cf | |||
| 71dd4f267b | |||
| 5758ff3459 | |||
| 6568b16ef9 | |||
| ef0412c449 | |||
| 031a7d396f | |||
| c3f5563e7f | |||
| 4ee3929e9d | |||
| 9ab409a640 | |||
| 9cafe75bf2 | |||
| 630b4eff8a | |||
| bf8ca59442 | |||
| 9dd28def67 | |||
| d785389fde | |||
| 1ccd770bce | |||
| 7719d378b0 | |||
| f26f8d4bb9 | |||
| 89d4aeac67 | |||
| dc94a683e7 | |||
| 04f5b08ba1 | |||
| 5924f4909f | |||
| c00bad38e2 | |||
| 11e1ef455f | |||
| 4af69b2758 |
+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 getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
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,12 +117,74 @@ export const unstarDocument = createAction({
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
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 downloadDocumentAsHTML = createAction({
|
||||
name: ({ t }) => t("HTML"),
|
||||
section: DocumentSection,
|
||||
keywords: "html export",
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
@@ -122,10 +193,37 @@ export const downloadDocument = createAction({
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download();
|
||||
document?.download("text/html");
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocumentAsMarkdown = createAction({
|
||||
name: ({ t }) => t("Markdown"),
|
||||
section: DocumentSection,
|
||||
keywords: "md markdown export",
|
||||
icon: <DownloadIcon />,
|
||||
iconInContextMenu: false,
|
||||
visible: ({ activeDocumentId, stores }) =>
|
||||
!!activeDocumentId && stores.policies.abilities(activeDocumentId).download,
|
||||
perform: ({ activeDocumentId, stores }) => {
|
||||
if (!activeDocumentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
document?.download("text/markdown");
|
||||
},
|
||||
});
|
||||
|
||||
export const downloadDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Download") : t("Download document"),
|
||||
section: DocumentSection,
|
||||
icon: <DownloadIcon />,
|
||||
keywords: "export",
|
||||
children: [downloadDocumentAsHTML, downloadDocumentAsMarkdown],
|
||||
});
|
||||
|
||||
export const duplicateDocument = createAction({
|
||||
name: ({ t, isContextMenu }) =>
|
||||
isContextMenu ? t("Duplicate") : t("Duplicate document"),
|
||||
@@ -260,8 +358,8 @@ export const importDocument = createAction({
|
||||
input.type = "file";
|
||||
input.accept = documents.importFileTypes.join(", ");
|
||||
|
||||
input.onchange = async (ev: Event) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
input.onchange = async (ev) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
@@ -296,10 +394,11 @@ export const createTemplate = createAction({
|
||||
return false;
|
||||
}
|
||||
const document = stores.documents.get(activeDocumentId);
|
||||
return (
|
||||
return !!(
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update &&
|
||||
!document?.isTemplate
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
@@ -328,15 +427,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 && (
|
||||
|
||||
+34
-11
@@ -1,22 +1,24 @@
|
||||
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 { supportedImageMimeTypes } from "@shared/utils/files";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ClickablePadding from "~/components/ClickablePadding";
|
||||
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";
|
||||
@@ -57,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,
|
||||
@@ -177,21 +180,41 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const files = getDataTransferFiles(event);
|
||||
|
||||
const view = ref?.current?.view;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert all files as attachments if any of the files are not images.
|
||||
const isAttachment = files.some(
|
||||
(file) => !supportedImageMimeTypes.includes(file.type)
|
||||
);
|
||||
|
||||
// Find a valid position at the end of the document
|
||||
// Find a valid position at the end of the document to insert our content
|
||||
const pos = TextSelection.near(
|
||||
view.state.doc.resolve(view.state.doc.nodeSize - 2)
|
||||
).from;
|
||||
|
||||
// If there are no files in the drop event attempt to parse the html
|
||||
// as a fragment and insert it at the end of the document
|
||||
if (files.length === 0) {
|
||||
const text =
|
||||
event.dataTransfer.getData("text/html") ||
|
||||
event.dataTransfer.getData("text/plain");
|
||||
|
||||
const dom = new DOMParser().parseFromString(text, "text/html");
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.insert(
|
||||
pos,
|
||||
ProsemirrorDOMParser.fromSchema(view.state.schema).parse(dom)
|
||||
)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert all files as attachments if any of the files are not images.
|
||||
const isAttachment = files.some(
|
||||
(file) => !AttachmentValidation.imageContentTypes.includes(file.type)
|
||||
);
|
||||
|
||||
insertFiles(view, event, pos, files, {
|
||||
uploadFile: onUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
@@ -289,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,
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMenuState, MenuButton, MenuItem } from "reakit/Menu";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { colorPalette } from "@shared/utils/collections";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import Flex from "~/components/Flex";
|
||||
import { LabelText } from "~/components/Input";
|
||||
@@ -200,18 +201,7 @@ export const icons = {
|
||||
keywords: "warning alert error",
|
||||
},
|
||||
};
|
||||
const colors = [
|
||||
"#4E5C6E",
|
||||
"#0366d6",
|
||||
"#9E5CF7",
|
||||
"#FF825C",
|
||||
"#FF5C80",
|
||||
"#FFBE0B",
|
||||
"#42DED1",
|
||||
"#00D084",
|
||||
"#FF4DFA",
|
||||
"#2F362F",
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onOpen?: () => void;
|
||||
onClose?: () => void;
|
||||
@@ -272,7 +262,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
|
||||
<ColorPicker
|
||||
color={color}
|
||||
onChange={(color) => onChange(color.hex, icon)}
|
||||
colors={colors}
|
||||
colors={colorPalette}
|
||||
triangle="hide"
|
||||
styles={{
|
||||
default: {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -153,7 +153,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
renderHeading,
|
||||
renderError,
|
||||
onEscape,
|
||||
children,
|
||||
} = this.props;
|
||||
|
||||
const showLoading =
|
||||
@@ -224,7 +223,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
});
|
||||
}}
|
||||
</ArrowKeyNavigation>
|
||||
{children}
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -8,7 +8,6 @@ import Collection from "~/models/Collection";
|
||||
import Flex from "~/components/Flex";
|
||||
import Error from "~/components/List/Error";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Text from "~/components/Text";
|
||||
import { createCollection } from "~/actions/definitions/collections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import DraggableCollectionLink from "./DraggableCollectionLink";
|
||||
@@ -63,11 +62,6 @@ function Collections() {
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
empty={
|
||||
<Empty type="tertiary" size="small">
|
||||
{t("Empty")}
|
||||
</Empty>
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
@@ -78,22 +72,14 @@ function Collections() {
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</PaginatedList>
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const Empty = styled(Text)`
|
||||
margin-left: 36px;
|
||||
margin-bottom: 0;
|
||||
line-height: 34px;
|
||||
font-style: italic;
|
||||
`;
|
||||
|
||||
const StyledError = styled(Error)`
|
||||
font-size: 15px;
|
||||
padding: 0 8px;
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useDrag, useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Collection from "~/models/Collection";
|
||||
import Document from "~/models/Document";
|
||||
import Fade from "~/components/Fade";
|
||||
@@ -319,7 +319,7 @@ function InnerDocumentLink(
|
||||
onSubmit={handleTitleChange}
|
||||
onEditing={handleTitleEditing}
|
||||
canUpdate={canUpdate}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
/>
|
||||
}
|
||||
isActive={(match, location: Location<{ starred?: boolean }>) =>
|
||||
|
||||
@@ -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);
|
||||
@@ -2,10 +2,10 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { ThemeProvider } from "styled-components";
|
||||
import { breakpoints } from "@shared/styles";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import { dark, light, lightMobile, darkMobile } from "@shared/styles/theme";
|
||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import GlobalStyles from "~/styles/globals";
|
||||
|
||||
const Theme: React.FC = ({ children }) => {
|
||||
const { ui } = useStores();
|
||||
|
||||
@@ -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,12 +7,13 @@ 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 { supportedImageMimeTypes } from "@shared/utils/files";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Input from "./Input";
|
||||
@@ -36,7 +37,7 @@ export type Props<T extends MenuItem = MenuItem> = {
|
||||
onFileUploadStop?: () => void;
|
||||
onShowToast: (message: string) => void;
|
||||
onLinkToolbarOpen?: () => void;
|
||||
onClose: () => void;
|
||||
onClose: (insertNewLine?: boolean) => void;
|
||||
onClearSearch: () => void;
|
||||
embeds?: EmbedDescriptor[];
|
||||
renderMenuItem: (
|
||||
@@ -123,7 +124,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
if (item) {
|
||||
this.insertItem(item);
|
||||
} else {
|
||||
this.props.onClose();
|
||||
this.props.onClose(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +183,9 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
insertItem = (item: any) => {
|
||||
switch (item.name) {
|
||||
case "image":
|
||||
return this.triggerFilePick(supportedImageMimeTypes.join(", "));
|
||||
return this.triggerFilePick(
|
||||
AttachmentValidation.imageContentTypes.join(", ")
|
||||
);
|
||||
case "attachment":
|
||||
return this.triggerFilePick("*");
|
||||
case "embed":
|
||||
@@ -275,7 +278,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
};
|
||||
|
||||
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = getDataTransferFiles(event);
|
||||
const files = getEventFiles(event);
|
||||
|
||||
const {
|
||||
view,
|
||||
@@ -424,11 +427,13 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
const embedItems: EmbedDescriptor[] = [];
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title && embed.icon) {
|
||||
embedItems.push({
|
||||
...embed,
|
||||
name: "embed",
|
||||
});
|
||||
if (embed.title) {
|
||||
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";
|
||||
@@ -44,7 +44,7 @@ type Props = {
|
||||
href: string,
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
) => void;
|
||||
onShowToast: (message: string, options: ToastOptions) => void;
|
||||
onShowToast: (message: string, options?: ToastOptions) => void;
|
||||
view: EditorView;
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
};
|
||||
|
||||
get href(): string {
|
||||
return this.props.mark ? 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 });
|
||||
};
|
||||
@@ -229,7 +229,12 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
|
||||
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
event.preventDefault();
|
||||
this.props.onClickLink(this.href, event);
|
||||
|
||||
try {
|
||||
this.props.onClickLink(this.href, event);
|
||||
} catch (err) {
|
||||
this.props.onShowToast(this.props.dictionary.openLinkError);
|
||||
}
|
||||
};
|
||||
|
||||
handleCreateLink = async (value: string) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+16
-4
@@ -16,6 +16,8 @@ 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 EditorContainer from "@shared/editor/components/Styles";
|
||||
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 +27,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";
|
||||
@@ -37,7 +41,6 @@ import EmojiMenu from "./components/EmojiMenu";
|
||||
import { SearchResult } from "./components/LinkEditor";
|
||||
import LinkToolbar from "./components/LinkToolbar";
|
||||
import SelectionToolbar from "./components/SelectionToolbar";
|
||||
import EditorContainer from "./components/Styles";
|
||||
import WithTheme from "./components/WithTheme";
|
||||
|
||||
export { default as Extension } from "@shared/editor/lib/Extension";
|
||||
@@ -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
|
||||
@@ -554,7 +559,14 @@ export class Editor extends React.PureComponent<
|
||||
this.setState({ blockMenuOpen: true, blockMenuSearch: search });
|
||||
};
|
||||
|
||||
private handleCloseBlockMenu = () => {
|
||||
private handleCloseBlockMenu = (insertNewLine?: boolean) => {
|
||||
if (insertNewLine) {
|
||||
const transaction = this.view.state.tr.split(
|
||||
this.view.state.selection.to
|
||||
);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
}
|
||||
if (!this.state.blockMenuOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||
import CollectionExport from "~/scenes/CollectionExport";
|
||||
@@ -117,8 +117,8 @@ function CollectionMenu({
|
||||
);
|
||||
|
||||
const handleFilePicked = React.useCallback(
|
||||
async (ev: React.FormEvent<HTMLInputElement>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
// Because this is the onChange handler it's possible for the change to be
|
||||
// from previously selecting a file to not selecting a file – aka empty
|
||||
@@ -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(
|
||||
() => [
|
||||
{
|
||||
@@ -266,7 +266,7 @@ function CollectionMenu({
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && canUserInTeam.export),
|
||||
visible: !!(collection && canUserInTeam.createExport),
|
||||
onClick: handleExport,
|
||||
icon: <ExportIcon />,
|
||||
},
|
||||
@@ -296,7 +296,7 @@ function CollectionMenu({
|
||||
alphabeticalSort,
|
||||
handleEdit,
|
||||
handlePermissions,
|
||||
canUserInTeam.export,
|
||||
canUserInTeam.createExport,
|
||||
handleExport,
|
||||
handleDelete,
|
||||
handleChangeSort,
|
||||
|
||||
+35
-197
@@ -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";
|
||||
@@ -23,21 +14,31 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
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,22 +157,9 @@ 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.FormEvent<HTMLInputElement>) => {
|
||||
const files = getDataTransferFiles(ev);
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = getEventFiles(ev);
|
||||
|
||||
// Because this is the onChange handler it's possible for the change to be
|
||||
// from previously selecting a file to not selecting a file – aka empty
|
||||
@@ -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,39 +285,18 @@ function DocumentMenu({
|
||||
visible: !!can.unpublish,
|
||||
icon: <UnpublishIcon />,
|
||||
},
|
||||
{
|
||||
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 />,
|
||||
},
|
||||
actionToMenuItem(archiveDocument, context),
|
||||
actionToMenuItem(moveDocument, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
{
|
||||
type: "route",
|
||||
title: t("History"),
|
||||
@@ -413,13 +306,6 @@ function DocumentMenu({
|
||||
visible: canViewHistory,
|
||||
icon: <HistoryIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Download"),
|
||||
onClick: document.download,
|
||||
visible: !!can.download,
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
-24
@@ -2,10 +2,10 @@ import { addDays, differenceInDays } from "date-fns";
|
||||
import { floor } from "lodash";
|
||||
import { action, autorun, computed, observable, set } from "mobx";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import unescape from "@shared/utils/unescape";
|
||||
import DocumentsStore from "~/stores/DocumentsStore";
|
||||
import User from "~/models/User";
|
||||
import type { NavigationNode } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Storage from "~/utils/Storage";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
import View from "./View";
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -386,21 +419,18 @@ export default class Document extends ParanoidModel {
|
||||
};
|
||||
}
|
||||
|
||||
download = async () => {
|
||||
// Ensure the document is upto date with latest server contents
|
||||
await this.fetch();
|
||||
const body = unescape(this.text);
|
||||
const blob = new Blob([`# ${this.title}\n\n${body}`], {
|
||||
type: "text/markdown",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
// Firefox support requires the anchor tag be in the DOM to trigger the dl
|
||||
if (document.body) {
|
||||
document.body.appendChild(a);
|
||||
}
|
||||
a.href = url;
|
||||
a.download = `${this.titleWithDefault}.md`;
|
||||
a.click();
|
||||
download = async (contentType: "text/html" | "text/markdown") => {
|
||||
await client.post(
|
||||
`/documents.export`,
|
||||
{
|
||||
id: this.id,
|
||||
},
|
||||
{
|
||||
download: true,
|
||||
headers: {
|
||||
accept: contentType,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { computed, observable } from "mobx";
|
||||
import { Role } from "@shared/types";
|
||||
import type { Role } from "@shared/types";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -3,7 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import IconPicker from "~/components/IconPicker";
|
||||
@@ -94,7 +94,7 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
value={name}
|
||||
required
|
||||
autoFocus
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -3,7 +3,10 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||
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";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
@@ -30,13 +33,13 @@ class CollectionNew extends React.Component<Props> {
|
||||
icon = "";
|
||||
|
||||
@observable
|
||||
color = "#4E5C6E";
|
||||
color = randomElement(colorPalette);
|
||||
|
||||
@observable
|
||||
sharing = true;
|
||||
|
||||
@observable
|
||||
permission = "read_write";
|
||||
permission = CollectionPermission.ReadWrite;
|
||||
|
||||
@observable
|
||||
isSaving: boolean;
|
||||
@@ -98,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>) => {
|
||||
@@ -128,7 +131,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={this.handleNameChange}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
value={this.name}
|
||||
required
|
||||
autoFocus
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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";
|
||||
@@ -18,7 +20,7 @@ type Props = {
|
||||
canEdit: boolean;
|
||||
onAdd?: () => void;
|
||||
onRemove?: () => void;
|
||||
onUpdate?: (permission: string) => void;
|
||||
onUpdate?: (permission: CollectionPermission) => void;
|
||||
};
|
||||
|
||||
const MemberListItem = ({
|
||||
@@ -30,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
|
||||
@@ -66,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}
|
||||
@@ -103,4 +101,4 @@ const Select = styled(InputSelect)`
|
||||
}
|
||||
` as React.ComponentType<SelectProps>;
|
||||
|
||||
export default MemberListItem;
|
||||
export default observer(MemberListItem);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -45,4 +46,4 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default UserListItem;
|
||||
export default observer(UserListItem);
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -2,17 +2,20 @@ import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RouteComponentProps, useLocation } from "react-router-dom";
|
||||
import { useTheme } from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { setCookie } from "tiny-cookie";
|
||||
import DocumentModel from "~/models/Document";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import Layout from "~/components/Layout";
|
||||
import Sidebar from "~/components/Sidebar/Shared";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { AuthorizationError, OfflineError } from "~/utils/errors";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Login from "../Login";
|
||||
import Document from "./components/Document";
|
||||
import Loading from "./components/Loading";
|
||||
@@ -75,6 +78,7 @@ function SharedDocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const [response, setResponse] = React.useState<Response>();
|
||||
const [error, setError] = React.useState<Error | null | undefined>();
|
||||
const { documents } = useStores();
|
||||
@@ -111,7 +115,22 @@ function SharedDocumentScene(props: Props) {
|
||||
return <ErrorOffline />;
|
||||
} else if (error instanceof AuthorizationError) {
|
||||
setCookie("postLoginRedirectPath", props.location.pathname);
|
||||
return <Login />;
|
||||
return (
|
||||
<Login>
|
||||
{(config) =>
|
||||
config?.name && isCloudHosted ? (
|
||||
<GetStarted>
|
||||
{t(
|
||||
"{{ teamName }} is using Outline to share documents, please login to continue.",
|
||||
{
|
||||
teamName: config.name,
|
||||
}
|
||||
)}
|
||||
</GetStarted>
|
||||
) : null
|
||||
}
|
||||
</Login>
|
||||
);
|
||||
} else {
|
||||
return <Error404 />;
|
||||
}
|
||||
@@ -146,4 +165,9 @@ function SharedDocumentScene(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const GetStarted = styled(Text)`
|
||||
text-align: center;
|
||||
margin-top: -8px;
|
||||
`;
|
||||
|
||||
export default observer(SharedDocumentScene);
|
||||
|
||||
@@ -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,18 +42,23 @@ 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;
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
|
||||
// Allows loading by /doc/slug-<urlId> or /doc/<id>
|
||||
const document =
|
||||
documents.getByUrl(match.params.documentSlug) ??
|
||||
documents.get(match.params.documentSlug);
|
||||
|
||||
const revision = revisionId ? revisions.get(revisionId) : undefined;
|
||||
const sharedTree = document
|
||||
? documents.getSharedTree(document.id)
|
||||
: 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(() => {
|
||||
@@ -81,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) {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||
import { light } from "@shared/styles/theme";
|
||||
import {
|
||||
getCurrentDateAsString,
|
||||
getCurrentDateTimeAsString,
|
||||
getCurrentTimeAsString,
|
||||
} from "@shared/utils/date";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import Document from "~/models/Document";
|
||||
import ContentEditable, { RefHandle } from "~/components/ContentEditable";
|
||||
import Star, { AnimatedStar } from "~/components/Star";
|
||||
@@ -132,7 +132,7 @@ const EditableTitle = React.forwardRef(
|
||||
$emojiWidth={emojiWidth}
|
||||
$isStarred={document.isStarred}
|
||||
autoFocus={!value}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
maxLength={DocumentValidation.maxTitleLength}
|
||||
readOnly={readOnly}
|
||||
dir="auto"
|
||||
ref={ref}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const canArchive = !document.isDraft && !document.isArchived;
|
||||
const collection = collections.get(document.collectionId);
|
||||
const nestedDocumentsCount = collection
|
||||
? collection.getDocumentChildren(document.id).length
|
||||
: 0;
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
@@ -94,9 +97,9 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
) : nestedDocumentsCount < 1 ? (
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents."
|
||||
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>."
|
||||
values={{
|
||||
documentTitle: document.titleWithDefault,
|
||||
}}
|
||||
@@ -104,6 +107,18 @@ function DocumentDelete({ document, onSubmit }: Props) {
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Trans
|
||||
count={nestedDocumentsCount}
|
||||
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>."
|
||||
values={{
|
||||
documentTitle: document.titleWithDefault,
|
||||
any: nestedDocumentsCount,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
{canArchive && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
@@ -15,20 +17,22 @@ type Props = {
|
||||
};
|
||||
|
||||
const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<>
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</>
|
||||
</Trans>
|
||||
) : (
|
||||
"Never signed in"
|
||||
t("Never signed in")
|
||||
)}
|
||||
{user.isInvited && <Badge>Invited</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||
@@ -37,7 +41,7 @@ const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
|
||||
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
|
||||
{onAdd && (
|
||||
<Button onClick={onAdd} neutral>
|
||||
Add
|
||||
{t("Add")}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
@@ -46,4 +50,4 @@ const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupMemberListItem;
|
||||
export default observer(GroupMemberListItem);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { PlusIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
@@ -14,6 +16,8 @@ type Props = {
|
||||
};
|
||||
|
||||
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={user.name}
|
||||
@@ -21,20 +25,20 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
subtitle={
|
||||
<>
|
||||
{user.lastActiveAt ? (
|
||||
<>
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</>
|
||||
</Trans>
|
||||
) : (
|
||||
"Never signed in"
|
||||
t("Never signed in")
|
||||
)}
|
||||
{user.isInvited && <Badge>Invited</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
|
||||
{user.isInvited && <Badge>{t("Invited")}</Badge>}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
|
||||
Add
|
||||
{t("Add")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
@@ -42,4 +46,4 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default UserListItem;
|
||||
export default observer(UserListItem);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -89,11 +89,15 @@ function AuthenticationProvider(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// If we're on a custom domain then the auth must point to the root
|
||||
// app.getoutline.com for authentication so that the state cookie can be set
|
||||
// and read.
|
||||
const isCustomDomain = parseDomain(window.location.origin).custom;
|
||||
const href = `${isCustomDomain ? env.URL : ""}${authUrl}`;
|
||||
// If we're on a custom domain or a subdomain then the auth must point to the
|
||||
// apex (env.URL) for authentication so that the state cookie can be set and read.
|
||||
// We pass the host into the auth URL so that the server can redirect on error
|
||||
// and keep the user on the same page.
|
||||
const { custom, teamSubdomain, host } = parseDomain(window.location.origin);
|
||||
const needsRedirect = custom || teamSubdomain;
|
||||
const href = needsRedirect
|
||||
? `${env.URL}${authUrl}?host=${encodeURI(host)}`
|
||||
: authUrl;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
|
||||
@@ -57,6 +57,15 @@ export default function Notices() {
|
||||
Please try again.
|
||||
</NoticeAlert>
|
||||
))}
|
||||
{notice === "invalid-authentication" &&
|
||||
(description ? (
|
||||
<NoticeAlert>{description}</NoticeAlert>
|
||||
) : (
|
||||
<NoticeAlert>
|
||||
Authentication failed – you do not have permission to access this
|
||||
team.
|
||||
</NoticeAlert>
|
||||
))}
|
||||
{notice === "expired-token" && (
|
||||
<NoticeAlert>
|
||||
Sorry, it looks like that sign-in link is no longer valid, please try
|
||||
|
||||
@@ -49,7 +49,11 @@ function Header({ config }: { config?: Config | undefined }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Login() {
|
||||
type Props = {
|
||||
children?: (config?: Config) => React.ReactNode;
|
||||
};
|
||||
|
||||
function Login({ children }: Props) {
|
||||
const location = useLocation();
|
||||
const query = useQuery();
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -174,11 +178,14 @@ function Login() {
|
||||
</GetStarted>
|
||||
</>
|
||||
) : (
|
||||
<StyledHeading centered>
|
||||
{t("Login to {{ authProviderName }}", {
|
||||
authProviderName: config.name || "Outline",
|
||||
})}
|
||||
</StyledHeading>
|
||||
<>
|
||||
<StyledHeading centered>
|
||||
{t("Login to {{ authProviderName }}", {
|
||||
authProviderName: config.name || "Outline",
|
||||
})}
|
||||
</StyledHeading>
|
||||
{children?.(config)}
|
||||
</>
|
||||
)}
|
||||
<Notices />
|
||||
{defaultProvider && (
|
||||
@@ -236,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
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import AvatarEditor from "react-avatar-editor";
|
||||
import Dropzone from "react-dropzone";
|
||||
import styled from "styled-components";
|
||||
import { AttachmentValidation } from "@shared/validations";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -134,7 +135,7 @@ class ImageUpload extends React.Component<RootStore & Props> {
|
||||
|
||||
return (
|
||||
<Dropzone
|
||||
accept="image/png, image/jpeg"
|
||||
accept={AttachmentValidation.avatarContentTypes.join(", ")}
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import User from "~/models/User";
|
||||
import Avatar from "~/components/Avatar";
|
||||
@@ -13,34 +14,31 @@ type Props = {
|
||||
showMenu: boolean;
|
||||
};
|
||||
|
||||
@observer
|
||||
class UserListItem extends React.Component<Props> {
|
||||
render() {
|
||||
const { user, showMenu } = this.props;
|
||||
const UserListItem = ({ user, showMenu }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={<Title>{user.name}</Title>}
|
||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||
subtitle={
|
||||
<>
|
||||
{user.email ? `${user.email} · ` : undefined}
|
||||
{user.lastActiveAt ? (
|
||||
<>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</>
|
||||
) : (
|
||||
"Invited"
|
||||
)}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
|
||||
{user.isSuspended && <Badge>Suspended</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={showMenu ? <UserMenu user={user} /> : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<ListItem
|
||||
title={<Title>{user.name}</Title>}
|
||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||
subtitle={
|
||||
<>
|
||||
{user.email ? `${user.email} · ` : undefined}
|
||||
{user.lastActiveAt ? (
|
||||
<Trans>
|
||||
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||
</Trans>
|
||||
) : (
|
||||
t("Invited")
|
||||
)}
|
||||
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
|
||||
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
|
||||
</>
|
||||
}
|
||||
actions={showMenu ? <UserMenu user={user} /> : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Title = styled.span`
|
||||
&:hover {
|
||||
@@ -49,4 +47,4 @@ const Title = styled.span`
|
||||
}
|
||||
`;
|
||||
|
||||
export default UserListItem;
|
||||
export default observer(UserListItem);
|
||||
|
||||
@@ -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
|
||||
|
||||
+82
-27
@@ -1,65 +1,120 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ReactHookWrappedInput as Input } from "~/components/Input";
|
||||
import Modal from "~/components/Modal";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type FormData = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onRequestClose: () => void;
|
||||
};
|
||||
|
||||
function UserDelete({ onRequestClose }: Props) {
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [isWaitingCode, setWaitingCode] = React.useState(false);
|
||||
const { auth } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const { register, handleSubmit: formHandleSubmit, formState } = useForm<
|
||||
FormData
|
||||
>();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
const handleRequestDelete = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
await auth.deleteUser();
|
||||
auth.logout();
|
||||
await auth.requestDelete();
|
||||
setWaitingCode(true);
|
||||
} catch (error) {
|
||||
showToast(error.message, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
[auth, showToast]
|
||||
);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
await auth.deleteUser(data);
|
||||
auth.logout();
|
||||
} catch (error) {
|
||||
showToast(error.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[auth, showToast]
|
||||
);
|
||||
|
||||
const inputProps = register("code", {
|
||||
required: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal isOpen title={t("Delete Account")} onRequestClose={onRequestClose}>
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Are you sure? Deleting your account will destroy identifying data
|
||||
associated with your user and cannot be undone. You will be
|
||||
immediately logged out of Outline and all your API tokens will be
|
||||
revoked.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned."
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("Delete My Account")}
|
||||
</Button>
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
{isWaitingCode ? (
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
A confirmation code has been sent to your email address,
|
||||
please enter the code below to permanantly destroy your
|
||||
account.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="<em>Note:</em> Signing back in will cause a new account to be automatically reprovisioned."
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="CODE"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
maxLength={8}
|
||||
required
|
||||
{...inputProps}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Are you sure? Deleting your account will destroy identifying
|
||||
data associated with your user and cannot be undone. You will
|
||||
be immediately logged out of Outline and all your API tokens
|
||||
will be revoked.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{env.EMAIL_ENABLED && !isWaitingCode ? (
|
||||
<Button type="submit" onClick={handleRequestDelete} neutral>
|
||||
{t("Continue")}…
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" disabled={formState.isSubmitting} danger>
|
||||
{formState.isSubmitting
|
||||
? `${t("Deleting")}…`
|
||||
: t("Delete My Account")}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</Flex>
|
||||
</Modal>
|
||||
|
||||
+16
-8
@@ -199,8 +199,13 @@ export default class AuthStore {
|
||||
};
|
||||
|
||||
@action
|
||||
deleteUser = async () => {
|
||||
await client.post(`/users.delete`);
|
||||
requestDelete = () => {
|
||||
return client.post(`/users.requestDelete`);
|
||||
};
|
||||
|
||||
@action
|
||||
deleteUser = async (data: { code: string }) => {
|
||||
await client.post(`/users.delete`, data);
|
||||
runInAction("AuthStore#updateUser", () => {
|
||||
this.user = null;
|
||||
this.team = null;
|
||||
@@ -255,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) {
|
||||
@@ -271,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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user