mirror of
https://github.com/outline/outline.git
synced 2026-06-14 03:45:00 +03:00
Compare commits
178 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75d1329c2e | |||
| 68e1483fa8 | |||
| 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 | |||
| 406d5e89c9 | |||
| 19df7bee78 | |||
| 16d576b4db | |||
| 78845d1535 | |||
| 36d718d123 | |||
| 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 | |||
| 95a734b11e | |||
| a20f02e5f3 | |||
| 134c69b4df | |||
| 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 | |||
| dee87f15af | |||
| 67885e7339 | |||
| 0b0a1b0169 | |||
| de18196fd8 | |||
| 96d1c4997b | |||
| 95f4fb2424 | |||
| 1247bb411e | |||
| 7ffb182034 | |||
| fc414e2dd4 | |||
| c3ec7b0877 | |||
| e509719c77 | |||
| a16cf72b73 | |||
| acabc00643 | |||
| e989999d6e | |||
| c3e149eb86 | |||
| 4c05fe422c | |||
| 47e73cee4e | |||
| d1b01d28e6 | |||
| 973cfc3fa3 | |||
| dd6084d044 | |||
| 206545f350 | |||
| e92d68a0a3 | |||
| 66dbcde29b | |||
| 465a8bd505 | |||
| aef62d1356 | |||
| 35e82beaf7 | |||
| 8bb88b8550 | |||
| da4a10e877 | |||
| caaf6dd76b | |||
| 2893924e9a | |||
| 32b7a7df00 | |||
| 97f8c0813c | |||
| 746dc30aeb | |||
| 4a46d19846 | |||
| 98106e7f6f | |||
| 1e808fc52c | |||
| ec8c0645ba | |||
| f90309e781 | |||
| d8f125f413 | |||
| c36e7bfbb6 | |||
| 831df67358 | |||
| c6fdffba77 | |||
| 4e189b8970 | |||
| 2f3dcb2520 | |||
| f36f5f13f4 | |||
| 5d498632c6 | |||
| 9cd26168e1 | |||
| ee10e1407a | |||
| c9af7ff889 | |||
| 27978b8fc4 | |||
| 62d9bf7105 | |||
| 1f3a1d4b86 | |||
| 8ebe4b27b1 | |||
| 0c30d2bb34 | |||
| f744d488f6 | |||
| 8ebf6e884f | |||
| 4438c80ea1 | |||
| 863f22750f | |||
| ee22a127f6 | |||
| c9cd424a8d | |||
| 108b5b934a | |||
| 94824af6e7 | |||
| 1c6eef3509 | |||
| 4e09356982 | |||
| 4b166432e6 | |||
| adb55fa965 | |||
| 7ce57c9c83 | |||
| b44dc726f3 | |||
| 117421b4cb | |||
| 930bfd5391 | |||
| 10f86ed218 | |||
| 9a6e09bafa | |||
| c65a88fc9f | |||
| e24a5adbd5 | |||
| cddb6b2c32 | |||
| ac467b2936 | |||
| 68ce304b48 |
@@ -35,6 +35,14 @@
|
||||
"displayName": false
|
||||
}
|
||||
]
|
||||
],
|
||||
"ignore": [
|
||||
"**/*.test.ts"
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"ignore": [
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+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 paramaters for rate limiter
|
||||
RATE_LIMITER_REQUESTS=5000
|
||||
RATE_LIMITER_DURATION_WINDOW=60
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
@@ -21,12 +20,12 @@
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"eslint-plugin-react-hooks",
|
||||
"import"
|
||||
],
|
||||
"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,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -3,6 +3,7 @@ build
|
||||
node_modules/*
|
||||
.env
|
||||
.log
|
||||
.vscode/*
|
||||
npm-debug.log
|
||||
stats.json
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"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/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 = () => {};
|
||||
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc"
|
||||
"../.eslintrc",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
"plugins": [
|
||||
"eslint-plugin-react-hooks",
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { ToolsIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { DebugSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Delete IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear database",
|
||||
section: DebugSection,
|
||||
perform: async ({ t }) => {
|
||||
await deleteAllDatabases();
|
||||
stores.toasts.showToast(t("IndexedDB cache deleted"));
|
||||
},
|
||||
});
|
||||
|
||||
export const development = createAction({
|
||||
name: ({ t }) => t("Development"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DebugSection,
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB],
|
||||
});
|
||||
|
||||
export const rootDebugActions = [development];
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ToolsIcon, TrashIcon, UserIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import stores from "~/stores";
|
||||
import { createAction } from "~/actions";
|
||||
import { DeveloperSection } from "~/actions/sections";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { deleteAllDatabases } from "~/utils/developer";
|
||||
|
||||
export const clearIndexedDB = createAction({
|
||||
name: ({ t }) => t("Delete IndexedDB cache"),
|
||||
icon: <TrashIcon />,
|
||||
keywords: "cache clear database",
|
||||
section: DeveloperSection,
|
||||
perform: async ({ t }) => {
|
||||
await deleteAllDatabases();
|
||||
stores.toasts.showToast(t("IndexedDB cache deleted"));
|
||||
},
|
||||
});
|
||||
|
||||
export const createTestUsers = createAction({
|
||||
name: "Create test users",
|
||||
icon: <UserIcon />,
|
||||
section: DeveloperSection,
|
||||
visible: () => env.ENVIRONMENT === "development",
|
||||
perform: async () => {
|
||||
const count = 10;
|
||||
|
||||
try {
|
||||
await client.post("/developer.create_test_users", { count });
|
||||
stores.toasts.showToast(`${count} test users created`);
|
||||
} catch (err) {
|
||||
stores.toasts.showToast(err.message, { type: "error" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const developer = createAction({
|
||||
name: ({ t }) => t("Developer"),
|
||||
keywords: "debug",
|
||||
icon: <ToolsIcon />,
|
||||
iconInContextMenu: false,
|
||||
section: DeveloperSection,
|
||||
visible: ({ event }) =>
|
||||
env.ENVIRONMENT === "development" ||
|
||||
(event instanceof KeyboardEvent && event.altKey),
|
||||
children: [clearIndexedDB, createTestUsers],
|
||||
});
|
||||
|
||||
export const rootDeveloperActions = [developer];
|
||||
@@ -11,9 +11,16 @@ import {
|
||||
ImportIcon,
|
||||
PinIcon,
|
||||
SearchIcon,
|
||||
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";
|
||||
@@ -260,8 +267,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 +303,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 +336,144 @@ 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,
|
||||
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,
|
||||
};
|
||||
|
||||
+4
-2
@@ -1,8 +1,9 @@
|
||||
import { rootCollectionActions } from "./definitions/collections";
|
||||
import { rootDebugActions } from "./definitions/debug";
|
||||
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 [
|
||||
@@ -11,5 +12,6 @@ export default [
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootSettingsActions,
|
||||
...rootDebugActions,
|
||||
...rootDeveloperActions,
|
||||
...rootTeamActions,
|
||||
];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionContext } from "~/types";
|
||||
|
||||
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
|
||||
|
||||
export const DebugSection = ({ t }: ActionContext) => t("Debug");
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ function Breadcrumb({
|
||||
return (
|
||||
<Flex justify="flex-start" align="center">
|
||||
{topLevelItems.map((item, index) => (
|
||||
<React.Fragment key={item.to || index}>
|
||||
<React.Fragment key={String(item.to) || index}>
|
||||
{item.icon}
|
||||
{item.to ? (
|
||||
<Item
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { ExpandedIcon } from "outline-icons";
|
||||
import { darken, lighten } from "polished";
|
||||
import * as React from "react";
|
||||
@@ -155,7 +156,7 @@ export type Props<T> = {
|
||||
primary?: boolean;
|
||||
fullwidth?: boolean;
|
||||
as?: T;
|
||||
to?: string;
|
||||
to?: LocationDescriptor;
|
||||
borderOnHover?: boolean;
|
||||
href?: string;
|
||||
"data-on"?: string;
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
|
||||
const ButtonLink: React.FC<Props> = React.forwardRef(
|
||||
(props: Props, ref: React.Ref<HTMLButtonElement>) => {
|
||||
return <Button {...props} ref={ref} />;
|
||||
}
|
||||
);
|
||||
|
||||
const Button = styled.button`
|
||||
const ButtonLink = styled.button`
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
@@ -10,7 +11,7 @@ type Props = {
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
dangerous?: boolean;
|
||||
to?: string;
|
||||
to?: LocationDescriptor;
|
||||
href?: string;
|
||||
target?: "_blank";
|
||||
as?: string | React.ComponentType<any>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -35,7 +36,7 @@ type Props = {
|
||||
showLastViewed?: boolean;
|
||||
showParentDocuments?: boolean;
|
||||
document: Document;
|
||||
to?: string;
|
||||
to?: LocationDescriptor;
|
||||
};
|
||||
|
||||
const DocumentMeta: React.FC<Props> = ({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,7 +13,7 @@ import useStores from "~/hooks/useStores";
|
||||
type Props = {
|
||||
document: Document;
|
||||
isDraft: boolean;
|
||||
to?: string;
|
||||
to?: LocationDescriptor;
|
||||
rtl?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
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";
|
||||
@@ -177,21 +178,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,
|
||||
|
||||
@@ -2,6 +2,7 @@ import styled from "styled-components";
|
||||
|
||||
const Empty = styled.p`
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
export default Empty;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+29
-30
@@ -104,31 +104,17 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
|
||||
export type Props = React.InputHTMLAttributes<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
> & {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
value?: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
labelHidden?: boolean;
|
||||
label?: string;
|
||||
flex?: boolean;
|
||||
short?: boolean;
|
||||
margin?: string | number;
|
||||
icon?: React.ReactNode;
|
||||
name?: string;
|
||||
pattern?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
autoComplete?: boolean | string;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
onChange?: (
|
||||
ev: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => unknown;
|
||||
innerRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
|
||||
onKeyDown?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
innerRef?: React.Ref<any>;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
};
|
||||
@@ -171,8 +157,6 @@ class Input extends React.Component<Props> {
|
||||
...rest
|
||||
} = this.props;
|
||||
|
||||
const InputComponent: React.ComponentType =
|
||||
type === "textarea" ? RealTextarea : RealInput;
|
||||
const wrappedLabel = <LabelText>{label}</LabelText>;
|
||||
|
||||
return (
|
||||
@@ -186,15 +170,24 @@ class Input extends React.Component<Props> {
|
||||
))}
|
||||
<Outline focused={this.focused} margin={margin}>
|
||||
{icon && <IconWrapper>{icon}</IconWrapper>}
|
||||
<InputComponent
|
||||
// @ts-expect-error no idea why this is not working
|
||||
ref={this.input}
|
||||
onBlur={this.handleBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
type={type === "textarea" ? undefined : type}
|
||||
{...rest}
|
||||
/>
|
||||
{type === "textarea" ? (
|
||||
<RealTextarea
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.props.onBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
) : (
|
||||
<RealInput
|
||||
ref={this.props.innerRef}
|
||||
onBlur={this.props.onBlur}
|
||||
onFocus={this.handleFocus}
|
||||
hasIcon={!!icon}
|
||||
type={type}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</Outline>
|
||||
</label>
|
||||
</Wrapper>
|
||||
@@ -202,4 +195,10 @@ class Input extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
export const ReactHookWrappedInput = React.forwardRef(
|
||||
(props: Omit<Props, "innerRef">, ref: React.Ref<any>) => {
|
||||
return <Input {...{ ...props, innerRef: ref }} />;
|
||||
}
|
||||
);
|
||||
|
||||
export default Input;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { DisconnectedIcon, WarningIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Empty from "~/components/Empty";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import { OfflineError } from "~/utils/errors";
|
||||
import ButtonLink from "../ButtonLink";
|
||||
import Flex from "../Flex";
|
||||
|
||||
type Props = {
|
||||
error: Error;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
export default function LoadingError({ error, retry, ...rest }: Props) {
|
||||
const { t } = useTranslation();
|
||||
useEventListener("online", retry);
|
||||
|
||||
const message =
|
||||
error instanceof OfflineError ? (
|
||||
<>
|
||||
<DisconnectedIcon color="currentColor" /> {t("You’re offline.")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WarningIcon color="currentColor" /> {t("Sorry, an error occurred.")}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Content {...rest}>
|
||||
<Flex align="center" gap={4}>
|
||||
{message}{" "}
|
||||
<ButtonLink onClick={() => retry()}>{t("Click to retry")}…</ButtonLink>
|
||||
</Flex>
|
||||
</Content>
|
||||
);
|
||||
}
|
||||
|
||||
const Content = styled(Empty)`
|
||||
padding: 8px 0;
|
||||
white-space: nowrap;
|
||||
|
||||
${ButtonLink} {
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -69,7 +69,11 @@ const ListItem = (
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = styled.a<{ $small?: boolean; $border?: boolean; to?: string }>`
|
||||
const Wrapper = styled.a<{
|
||||
$small?: boolean;
|
||||
$border?: boolean;
|
||||
to?: string;
|
||||
}>`
|
||||
display: flex;
|
||||
padding: ${(props) => (props.$border === false ? 0 : "8px 0")};
|
||||
margin: ${(props) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentListItem from "~/components/DocumentListItem";
|
||||
import Error from "~/components/List/Error";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
|
||||
type Props = {
|
||||
@@ -40,6 +41,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
|
||||
heading={heading}
|
||||
fetch={fetch}
|
||||
options={options}
|
||||
renderError={(props) => <Error {...props} />}
|
||||
renderItem={(item: Document, _index, compositeProps) => (
|
||||
<DocumentListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -34,12 +34,19 @@ type Props<T> = WithTranslation &
|
||||
index: number,
|
||||
compositeProps: CompositeStateReturn
|
||||
) => React.ReactNode;
|
||||
renderError?: (options: {
|
||||
error: Error;
|
||||
retry: () => void;
|
||||
}) => React.ReactNode;
|
||||
renderHeading?: (name: React.ReactElement<any> | string) => React.ReactNode;
|
||||
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
@observer
|
||||
class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
@observable
|
||||
error?: Error;
|
||||
|
||||
@observable
|
||||
isFetchingMore = false;
|
||||
|
||||
@@ -80,6 +87,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
this.isFetchingMore = false;
|
||||
};
|
||||
|
||||
@action
|
||||
fetchResults = async () => {
|
||||
if (!this.props.fetch) {
|
||||
return;
|
||||
@@ -87,25 +95,30 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
this.isFetching = true;
|
||||
const counter = ++this.fetchCounter;
|
||||
const limit = DEFAULT_PAGINATION_LIMIT;
|
||||
this.error = undefined;
|
||||
|
||||
const results = await this.props.fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
});
|
||||
try {
|
||||
const results = await this.props.fetch({
|
||||
limit,
|
||||
offset: this.offset,
|
||||
...this.props.options,
|
||||
});
|
||||
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += limit;
|
||||
}
|
||||
if (results && (results.length === 0 || results.length < limit)) {
|
||||
this.allowLoadMore = false;
|
||||
} else {
|
||||
this.offset += limit;
|
||||
}
|
||||
|
||||
this.renderCount += limit;
|
||||
|
||||
// only the most recent fetch should end the loading state
|
||||
if (counter >= this.fetchCounter) {
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
this.renderCount += limit;
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
} finally {
|
||||
// only the most recent fetch should end the loading state
|
||||
if (counter >= this.fetchCounter) {
|
||||
this.isFetching = false;
|
||||
this.isFetchingMore = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,6 +151,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
auth,
|
||||
empty = null,
|
||||
renderHeading,
|
||||
renderError,
|
||||
onEscape,
|
||||
} = this.props;
|
||||
|
||||
@@ -157,6 +171,10 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
}
|
||||
|
||||
if (items?.length === 0) {
|
||||
if (this.error && renderError) {
|
||||
return renderError({ error: this.error, retry: this.fetchResults });
|
||||
}
|
||||
|
||||
return empty;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import { inviteUser } from "~/actions/definitions/users";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import OrganizationMenu from "~/menus/OrganizationMenu";
|
||||
@@ -34,12 +35,15 @@ function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const user = useCurrentUser();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
React.useEffect(() => {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
}, [documents]);
|
||||
if (!user.isViewer) {
|
||||
documents.fetchDrafts();
|
||||
documents.fetchTemplates();
|
||||
}
|
||||
}, [documents, user.isViewer]);
|
||||
|
||||
const [dndArea, setDndArea] = React.useState();
|
||||
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
|
||||
|
||||
@@ -3,12 +3,13 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Collection from "~/models/Collection";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Error from "~/components/List/Error";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import { createCollection } from "~/actions/definitions/collections";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import DraggableCollectionLink from "./DraggableCollectionLink";
|
||||
import DropCursor from "./DropCursor";
|
||||
import Header from "./Header";
|
||||
@@ -18,39 +19,10 @@ import SidebarAction from "./SidebarAction";
|
||||
import { DragObject } from "./SidebarLink";
|
||||
|
||||
function Collections() {
|
||||
const [isFetching, setFetching] = React.useState(false);
|
||||
const [fetchError, setFetchError] = React.useState();
|
||||
const { documents, collections } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const isPreloaded = !!collections.orderedData.length;
|
||||
const { t } = useTranslation();
|
||||
const orderedCollections = collections.orderedData;
|
||||
|
||||
React.useEffect(() => {
|
||||
async function load() {
|
||||
if (!collections.isLoaded && !isFetching && !fetchError) {
|
||||
try {
|
||||
setFetching(true);
|
||||
await collections.fetchPage({
|
||||
limit: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(
|
||||
t("Collections could not be loaded, please reload the app"),
|
||||
{
|
||||
type: "error",
|
||||
}
|
||||
);
|
||||
setFetchError(error);
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
}, [collections, isFetching, showToast, fetchError, t]);
|
||||
|
||||
const [
|
||||
{ isCollectionDropping, isDraggingAnyCollection },
|
||||
dropToReorderCollection,
|
||||
@@ -71,45 +43,46 @@ function Collections() {
|
||||
}),
|
||||
});
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{isDraggingAnyCollection && (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{orderedCollections.map((collection: Collection, index: number) => (
|
||||
<DraggableCollectionLink
|
||||
key={collection.id}
|
||||
collection={collection}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
))}
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</>
|
||||
);
|
||||
|
||||
if (!collections.isLoaded || fetchError) {
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<PlaceholderCollections />
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="collections" title={t("Collections")}>
|
||||
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</Relative>
|
||||
<Relative>
|
||||
<PaginatedList
|
||||
aria-label={t("Collections")}
|
||||
items={collections.orderedData}
|
||||
fetch={collections.fetchPage}
|
||||
options={{ limit: 100 }}
|
||||
loading={<PlaceholderCollections />}
|
||||
heading={
|
||||
isDraggingAnyCollection ? (
|
||||
<DropCursor
|
||||
isActiveDrop={isCollectionDropping}
|
||||
innerRef={dropToReorderCollection}
|
||||
position="top"
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
renderError={(props) => <StyledError {...props} />}
|
||||
renderItem={(item: Collection, index) => (
|
||||
<DraggableCollectionLink
|
||||
key={item.id}
|
||||
collection={item}
|
||||
activeDocument={documents.active}
|
||||
prefetchDocument={documents.prefetchDocument}
|
||||
belowCollection={orderedCollections[index + 1]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<SidebarAction action={createCollection} depth={0} />
|
||||
</Relative>
|
||||
</Header>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledError = styled(Error)`
|
||||
font-size: 15px;
|
||||
padding: 0 8px;
|
||||
`;
|
||||
|
||||
export default observer(Collections);
|
||||
|
||||
@@ -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 }>) =>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// This file is pulled almost 100% from react-router with the addition of one
|
||||
// thing, automatic scroll to the active link. It's worth the copy paste because
|
||||
// it avoids recalculating the link match again.
|
||||
import { Location, createLocation } from "history";
|
||||
import { Location, createLocation, LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import {
|
||||
__RouterContext as RouterContext,
|
||||
@@ -13,12 +13,12 @@ import { Link } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
|
||||
const resolveToLocation = (
|
||||
to: string | Record<string, any>,
|
||||
to: LocationDescriptor | ((location: Location) => LocationDescriptor),
|
||||
currentLocation: Location
|
||||
) => (typeof to === "function" ? to(currentLocation) : to);
|
||||
|
||||
const normalizeToLocation = (
|
||||
to: string | Record<string, any>,
|
||||
to: LocationDescriptor,
|
||||
currentLocation: Location
|
||||
) => {
|
||||
return typeof to === "string"
|
||||
@@ -30,17 +30,15 @@ const joinClassnames = (...classnames: (string | undefined)[]) => {
|
||||
return classnames.filter((i) => i).join(" ");
|
||||
};
|
||||
|
||||
export type Props = React.HTMLAttributes<HTMLAnchorElement> & {
|
||||
export type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
|
||||
activeClassName?: string;
|
||||
activeStyle?: React.CSSProperties;
|
||||
className?: string;
|
||||
scrollIntoViewIfNeeded?: boolean;
|
||||
exact?: boolean;
|
||||
isActive?: (match: match | null, location: Location) => boolean;
|
||||
location?: Location;
|
||||
strict?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
to: string | Record<string, any>;
|
||||
to: LocationDescriptor;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme, css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
@@ -14,8 +15,7 @@ export type DragObject = NavigationNode & {
|
||||
};
|
||||
|
||||
type Props = Omit<NavLinkProps, "to"> & {
|
||||
to?: string | Record<string, any>;
|
||||
href?: string | Record<string, any>;
|
||||
to?: LocationDescriptor;
|
||||
innerRef?: (ref: HTMLElement | null | undefined) => void;
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
|
||||
@@ -32,6 +32,7 @@ export default function Version() {
|
||||
|
||||
return (
|
||||
<SidebarLink
|
||||
target="_blank"
|
||||
href="https://github.com/outline/outline/releases"
|
||||
label={
|
||||
<>
|
||||
|
||||
@@ -3,22 +3,17 @@ import { find } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import io from "socket.io-client";
|
||||
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 = {
|
||||
type SocketWithAuthentication = Socket & {
|
||||
authenticated?: boolean;
|
||||
disconnected: boolean;
|
||||
disconnect: () => void;
|
||||
close: () => void;
|
||||
on: (event: string, callback: (data: any) => void) => void;
|
||||
emit: (event: string, data: any) => void;
|
||||
io: any;
|
||||
};
|
||||
|
||||
export const SocketContext: any = React.createContext<SocketWithAuthentication | null>(
|
||||
export const SocketContext = React.createContext<SocketWithAuthentication | null>(
|
||||
null
|
||||
);
|
||||
|
||||
@@ -98,7 +93,7 @@ class SocketProvider extends React.Component<Props> {
|
||||
|
||||
// on reconnection, reset the transports option, as the Websocket
|
||||
// connection may have failed (caused by proxy, firewall, browser, ...)
|
||||
this.socket.on("reconnect_attempt", () => {
|
||||
this.socket.io.on("reconnect_attempt", () => {
|
||||
if (this.socket) {
|
||||
this.socket.io.opts.transports = auth?.team?.domain
|
||||
? ["websocket"]
|
||||
@@ -154,7 +149,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.remove(documentId);
|
||||
return;
|
||||
}
|
||||
@@ -216,7 +214,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
documents.removeCollectionDocuments(collectionId);
|
||||
memberships.removeCollectionMemberships(collectionId);
|
||||
collections.remove(collectionId);
|
||||
@@ -245,7 +246,10 @@ class SocketProvider extends React.Component<Props> {
|
||||
force: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.statusCode === 404 || err.statusCode === 403) {
|
||||
if (
|
||||
err instanceof AuthorizationError ||
|
||||
err instanceof NotFoundError
|
||||
) {
|
||||
groups.remove(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { m } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import NavLinkWithChildrenFunc from "~/components/NavLink";
|
||||
import NavLink from "~/components/NavLink";
|
||||
|
||||
type Props = Omit<
|
||||
React.ComponentProps<typeof NavLinkWithChildrenFunc>,
|
||||
"children"
|
||||
> & {
|
||||
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
|
||||
to: string;
|
||||
exact?: boolean;
|
||||
};
|
||||
|
||||
const TabLink = styled(NavLinkWithChildrenFunc)`
|
||||
const TabLink = styled(NavLink)`
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
@@ -72,4 +73,4 @@ const TableFromParams = (
|
||||
);
|
||||
};
|
||||
|
||||
export default TableFromParams;
|
||||
export default observer(TableFromParams);
|
||||
|
||||
@@ -6,15 +6,13 @@ import useStores from "~/hooks/useStores";
|
||||
type StoreProps = keyof RootStore;
|
||||
|
||||
function withStores<
|
||||
P extends React.ComponentType<React.ComponentProps<P> & RootStore>,
|
||||
P extends React.ComponentType<ResolvedProps & RootStore>,
|
||||
ResolvedProps = JSX.LibraryManagedAttributes<
|
||||
P,
|
||||
Omit<React.ComponentProps<P>, StoreProps>
|
||||
>
|
||||
>(WrappedComponent: P): React.FC<Omit<ResolvedProps, StoreProps>> {
|
||||
const ComponentWithStore = (
|
||||
props: Omit<React.ComponentProps<P>, StoreProps>
|
||||
) => {
|
||||
>(WrappedComponent: P): React.FC<ResolvedProps> {
|
||||
const ComponentWithStore = (props: ResolvedProps) => {
|
||||
const stores = useStores();
|
||||
return <WrappedComponent {...(props as any)} {...stores} />;
|
||||
};
|
||||
|
||||
@@ -11,8 +11,8 @@ import { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
|
||||
import { EmbedDescriptor, 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 +36,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 +123,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 +182,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 +277,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,7 +426,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
|
||||
const embedItems: EmbedDescriptor[] = [];
|
||||
|
||||
for (const embed of embeds) {
|
||||
if (embed.title && embed.icon) {
|
||||
if (embed.title) {
|
||||
embedItems.push({
|
||||
...embed,
|
||||
name: "embed",
|
||||
|
||||
@@ -11,8 +11,7 @@ import { setTextSelection } from "prosemirror-utils";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import isUrl from "@shared/editor/lib/isUrl";
|
||||
import { isInternalUrl } 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";
|
||||
@@ -45,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;
|
||||
};
|
||||
|
||||
@@ -71,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 {
|
||||
@@ -114,17 +113,7 @@ class LinkEditor extends React.Component<Props, State> {
|
||||
|
||||
this.discardInputValue = true;
|
||||
const { from, to } = this.props;
|
||||
|
||||
// Make sure a protocol is added to the beginning of the input if it's
|
||||
// likely an absolute URL that was entered without one.
|
||||
if (
|
||||
!isUrl(href) &&
|
||||
!href.startsWith("/") &&
|
||||
!href.startsWith("#") &&
|
||||
!href.startsWith("mailto:")
|
||||
) {
|
||||
href = `https://${href}`;
|
||||
}
|
||||
href = sanitizeUrl(href) ?? "";
|
||||
|
||||
this.props.onSelectLink({ href, title, from, to });
|
||||
};
|
||||
@@ -240,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) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
import { lighten, transparentize } from "polished";
|
||||
import { darken, lighten, transparentize } from "polished";
|
||||
import styled from "styled-components";
|
||||
import { depths } from "@shared/styles";
|
||||
|
||||
const EditorStyles = styled.div<{
|
||||
rtl: boolean;
|
||||
@@ -359,6 +360,7 @@ const EditorStyles = styled.div<{
|
||||
|
||||
.heading-actions {
|
||||
opacity: 0;
|
||||
z-index: ${depths.editorHeadingActions};
|
||||
background: ${(props) => props.theme.background};
|
||||
margin-${(props) => (props.rtl ? "right" : "left")}: -26px;
|
||||
flex-direction: ${(props) => (props.rtl ? "row-reverse" : "row")};
|
||||
@@ -405,6 +407,7 @@ const EditorStyles = styled.div<{
|
||||
&.collapsed {
|
||||
svg {
|
||||
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
|
||||
pointer-events: none;
|
||||
}
|
||||
transition-delay: 0.1s;
|
||||
opacity: 1;
|
||||
@@ -773,20 +776,45 @@ const EditorStyles = styled.div<{
|
||||
|
||||
select,
|
||||
button {
|
||||
background: ${(props) => props.theme.background};
|
||||
color: ${(props) => props.theme.text};
|
||||
border-width: 1px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: ${(props) => props.theme.buttonNeutralBackground};
|
||||
color: ${(props) => props.theme.buttonNeutralText};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${(props) =>
|
||||
props.theme.buttonNeutralBorder} 0 0 0 1px inset;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
height: 18px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
appearance: none !important;
|
||||
padding: 6px 8px;
|
||||
display: none;
|
||||
|
||||
&::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: ${(props) =>
|
||||
darken(0.05, props.theme.buttonNeutralBackground)};
|
||||
box-shadow: rgba(0, 0, 0, 0.07) 0px 1px 2px, ${(props) =>
|
||||
props.theme.buttonNeutralBorder} 0 0 0 1px inset;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 2px 4px;
|
||||
select {
|
||||
background-image: url('data:image/svg+xml;utf8,<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <path fill-rule="evenodd" clip-rule="evenodd" d="M9.03087 9C8.20119 9 7.73238 9.95209 8.23824 10.6097L11.2074 14.4696C11.6077 14.99 12.3923 14.99 12.7926 14.4696L15.7618 10.6097C16.2676 9.95209 15.7988 9 14.9691 9L9.03087 9Z" fill="currentColor"/> </svg>');
|
||||
background-repeat: no-repeat;
|
||||
background-position: center right;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
&:focus-within,
|
||||
&:hover {
|
||||
select {
|
||||
display: ${(props) => (props.readOnly ? "none" : "inline")};
|
||||
@@ -803,6 +831,49 @@ const EditorStyles = styled.div<{
|
||||
button:active {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
button.show-source-button {
|
||||
display: none;
|
||||
}
|
||||
button.show-diagram-button {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
&.code-hidden {
|
||||
button,
|
||||
select,
|
||||
button.show-diagram-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button.show-source-button {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mermaid-diagram-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: ${(props) => props.theme.codeBackground};
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${(props) => props.theme.codeBorder};
|
||||
padding: 8px;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
|
||||
* {
|
||||
font-family: ${(props) => props.theme.fontFamily};
|
||||
}
|
||||
|
||||
&.diagram-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
|
||||
@@ -430,7 +430,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 +554,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;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { isInTable } from "prosemirror-tables";
|
||||
import isInCode from "@shared/editor/queries/isInCode";
|
||||
import isInList from "@shared/editor/queries/isInList";
|
||||
import isMarkActive from "@shared/editor/queries/isMarkActive";
|
||||
import isNodeActive from "@shared/editor/queries/isNodeActive";
|
||||
@@ -28,6 +29,7 @@ export default function formattingMenuItems(
|
||||
const { schema } = state;
|
||||
const isTable = isInTable(state);
|
||||
const isList = isInList(state);
|
||||
const isCode = isInCode(state);
|
||||
const allowBlocks = !isTable && !isList;
|
||||
|
||||
return [
|
||||
@@ -47,19 +49,21 @@ export default function formattingMenuItems(
|
||||
tooltip: dictionary.strong,
|
||||
icon: BoldIcon,
|
||||
active: isMarkActive(schema.marks.strong),
|
||||
visible: !isCode,
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
tooltip: dictionary.strikethrough,
|
||||
icon: StrikethroughIcon,
|
||||
active: isMarkActive(schema.marks.strikethrough),
|
||||
visible: !isCode,
|
||||
},
|
||||
{
|
||||
name: "highlight",
|
||||
tooltip: dictionary.mark,
|
||||
icon: HighlightIcon,
|
||||
active: isMarkActive(schema.marks.highlight),
|
||||
visible: !isTemplate,
|
||||
visible: !isTemplate && !isCode,
|
||||
},
|
||||
{
|
||||
name: "code_inline",
|
||||
@@ -69,7 +73,7 @@ export default function formattingMenuItems(
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: allowBlocks,
|
||||
visible: allowBlocks && !isCode,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
@@ -77,7 +81,7 @@ export default function formattingMenuItems(
|
||||
icon: Heading1Icon,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 1 }),
|
||||
attrs: { level: 1 },
|
||||
visible: allowBlocks,
|
||||
visible: allowBlocks && !isCode,
|
||||
},
|
||||
{
|
||||
name: "heading",
|
||||
@@ -85,7 +89,7 @@ export default function formattingMenuItems(
|
||||
icon: Heading2Icon,
|
||||
active: isNodeActive(schema.nodes.heading, { level: 2 }),
|
||||
attrs: { level: 2 },
|
||||
visible: allowBlocks,
|
||||
visible: allowBlocks && !isCode,
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
@@ -93,11 +97,11 @@ export default function formattingMenuItems(
|
||||
icon: BlockQuoteIcon,
|
||||
active: isNodeActive(schema.nodes.blockquote),
|
||||
attrs: { level: 2 },
|
||||
visible: allowBlocks,
|
||||
visible: allowBlocks && !isCode,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: allowBlocks || isList,
|
||||
visible: (allowBlocks || isList) && !isCode,
|
||||
},
|
||||
{
|
||||
name: "checkbox_list",
|
||||
@@ -105,24 +109,25 @@ export default function formattingMenuItems(
|
||||
icon: TodoListIcon,
|
||||
keywords: "checklist checkbox task",
|
||||
active: isNodeActive(schema.nodes.checkbox_list),
|
||||
visible: allowBlocks || isList,
|
||||
visible: (allowBlocks || isList) && !isCode,
|
||||
},
|
||||
{
|
||||
name: "bullet_list",
|
||||
tooltip: dictionary.bulletList,
|
||||
icon: BulletedListIcon,
|
||||
active: isNodeActive(schema.nodes.bullet_list),
|
||||
visible: allowBlocks || isList,
|
||||
visible: (allowBlocks || isList) && !isCode,
|
||||
},
|
||||
{
|
||||
name: "ordered_list",
|
||||
tooltip: dictionary.orderedList,
|
||||
icon: OrderedListIcon,
|
||||
active: isNodeActive(schema.nodes.ordered_list),
|
||||
visible: allowBlocks || isList,
|
||||
visible: (allowBlocks || isList) && !isCode,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
visible: !isCode,
|
||||
},
|
||||
{
|
||||
name: "link",
|
||||
@@ -130,6 +135,7 @@ export default function formattingMenuItems(
|
||||
icon: LinkIcon,
|
||||
active: isMarkActive(schema.marks.link),
|
||||
attrs: { href: "" },
|
||||
visible: !isCode,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TeamIcon,
|
||||
BeakerIcon,
|
||||
DownloadIcon,
|
||||
WebhooksIcon,
|
||||
} from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -25,6 +26,7 @@ import Security from "~/scenes/Settings/Security";
|
||||
import Shares from "~/scenes/Settings/Shares";
|
||||
import Slack from "~/scenes/Settings/Slack";
|
||||
import Tokens from "~/scenes/Settings/Tokens";
|
||||
import Webhooks from "~/scenes/Settings/Webhooks";
|
||||
import Zapier from "~/scenes/Settings/Zapier";
|
||||
import SlackIcon from "~/components/SlackIcon";
|
||||
import ZapierIcon from "~/components/ZapierIcon";
|
||||
@@ -46,6 +48,7 @@ type SettingsPage =
|
||||
| "Shares"
|
||||
| "Import"
|
||||
| "Export"
|
||||
| "Webhooks"
|
||||
| "Slack"
|
||||
| "Zapier";
|
||||
|
||||
@@ -146,7 +149,7 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: t("Import"),
|
||||
path: "/settings/import",
|
||||
component: Import,
|
||||
enabled: can.manage,
|
||||
enabled: can.createImport,
|
||||
group: t("Team"),
|
||||
icon: NewDocumentIcon,
|
||||
},
|
||||
@@ -154,11 +157,19 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: t("Export"),
|
||||
path: "/settings/export",
|
||||
component: Export,
|
||||
enabled: can.export,
|
||||
enabled: can.createExport,
|
||||
group: t("Team"),
|
||||
icon: DownloadIcon,
|
||||
},
|
||||
// Intergrations
|
||||
// Integrations
|
||||
Webhooks: {
|
||||
name: t("Webhooks"),
|
||||
path: "/settings/webhooks",
|
||||
component: Webhooks,
|
||||
enabled: can.createWebhookSubscription,
|
||||
group: t("Integrations"),
|
||||
icon: WebhooksIcon,
|
||||
},
|
||||
Slack: {
|
||||
name: "Slack",
|
||||
path: "/settings/integrations/slack",
|
||||
@@ -176,7 +187,14 @@ const useAuthorizedSettingsConfig = () => {
|
||||
icon: ZapierIcon,
|
||||
},
|
||||
}),
|
||||
[can.createApiKey, can.export, can.manage, can.update, t]
|
||||
[
|
||||
can.createApiKey,
|
||||
can.createWebhookSubscription,
|
||||
can.createExport,
|
||||
can.createImport,
|
||||
can.update,
|
||||
t,
|
||||
]
|
||||
);
|
||||
|
||||
const enabledConfigs = React.useMemo(
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function useDictionary() {
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
copy: t("Copy"),
|
||||
createLink: t("Create link"),
|
||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||
createNewDoc: t("Create a new doc"),
|
||||
@@ -52,6 +53,7 @@ export default function useDictionary() {
|
||||
noResults: t("No results"),
|
||||
openLink: t("Open link"),
|
||||
goToLink: t("Go to link"),
|
||||
openLinkError: t("Sorry, that type of link is not supported"),
|
||||
orderedList: t("Ordered list"),
|
||||
pageBreak: t("Page break"),
|
||||
pasteLink: `${t("Paste a link")}…`,
|
||||
@@ -69,6 +71,8 @@ export default function useDictionary() {
|
||||
table: t("Table"),
|
||||
tip: t("Tip"),
|
||||
tipNotice: t("Tip notice"),
|
||||
showDiagram: t("Show diagram"),
|
||||
showSource: t("Show source"),
|
||||
warning: t("Warning"),
|
||||
warningNotice: t("Warning notice"),
|
||||
insertDate: t("Current date"),
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
+27
-196
@@ -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,29 @@ 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,
|
||||
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 +93,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,24 +122,6 @@ 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 canViewHistory = can.read && !can.restore;
|
||||
@@ -205,22 +155,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 +217,7 @@ function DocumentMenu({
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
aria-label={t("Document options")}
|
||||
onOpen={handleOpen}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Template
|
||||
@@ -313,21 +250,8 @@ 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(unstarDocument, context),
|
||||
actionToMenuItem(starDocument, context),
|
||||
actionToMenuItem(pinDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
@@ -348,22 +272,9 @@ function DocumentMenu({
|
||||
visible: !!can.createChildDocument,
|
||||
icon: <NewDocumentIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Import document"),
|
||||
visible: can.createChildDocument,
|
||||
onClick: handleImportDocument,
|
||||
icon: <ImportIcon />,
|
||||
},
|
||||
// Templatize document
|
||||
actionToMenuItem(importDocument, context),
|
||||
actionToMenuItem(createTemplate, context),
|
||||
{
|
||||
type: "button",
|
||||
title: t("Duplicate"),
|
||||
onClick: handleDuplicate,
|
||||
visible: !!can.update,
|
||||
icon: <DuplicateIcon />,
|
||||
},
|
||||
actionToMenuItem(duplicateDocument, context),
|
||||
{
|
||||
type: "button",
|
||||
title: t("Unpublish"),
|
||||
@@ -371,36 +282,10 @@ 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(deleteDocument, context),
|
||||
actionToMenuItem(permanentlyDeleteDocument, context),
|
||||
{
|
||||
type: "separator",
|
||||
},
|
||||
@@ -413,13 +298,7 @@ function DocumentMenu({
|
||||
visible: canViewHistory,
|
||||
icon: <HistoryIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: t("Download"),
|
||||
onClick: document.download,
|
||||
visible: !!can.download,
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
actionToMenuItem(downloadDocument, context),
|
||||
{
|
||||
type: "button",
|
||||
title: t("Print"),
|
||||
@@ -447,7 +326,7 @@ function DocumentMenu({
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showDisplayOptions && !isMobile && (
|
||||
{showDisplayOptions && !isMobile && can.update && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
@@ -464,54 +343,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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router";
|
||||
import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Modal from "~/components/Modal";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
function EmptyTrashMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const [showModal, setShowModal] = React.useState(false);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const { showToast } = useToasts();
|
||||
const history = useHistory();
|
||||
|
||||
const [trashed, setTrashed] = React.useState<Document[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function getTrashed() {
|
||||
const trashedDocs = await documents.fetchDeleted();
|
||||
setTrashed(trashedDocs);
|
||||
}
|
||||
getTrashed();
|
||||
}, [documents]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await documents.emptyTrash();
|
||||
// If all trashed documents were not removed.
|
||||
// E.g. More trashed docs than `emptyTrash` limit.
|
||||
const trashedDocs = await documents.fetchDeleted();
|
||||
setTrashed(trashedDocs);
|
||||
showToast(t("Trash permanently deleted"), {
|
||||
type: "success",
|
||||
});
|
||||
history.push("/trash");
|
||||
setShowModal(false);
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setTrashed([]);
|
||||
}
|
||||
},
|
||||
[documents, history, showToast, t]
|
||||
);
|
||||
|
||||
if (!trashed.length) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button icon={<TrashIcon />} onClick={() => setShowModal(true)}>
|
||||
{t("Empty trash")}
|
||||
</Button>
|
||||
{showModal && (
|
||||
<Modal
|
||||
title={t("Empty trash")}
|
||||
isOpen={showModal}
|
||||
onRequestClose={() => setShowModal(false)}
|
||||
isCentered
|
||||
>
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Are you sure you want to permanently delete all documents in the trash? This action cannot be undone."
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
<Button type="submit" danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(EmptyTrashMenu);
|
||||
@@ -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
-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";
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { observable } from "mobx";
|
||||
import BaseModel from "./BaseModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
class WebhookSubscription extends BaseModel {
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
name: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
url: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
enabled: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export default WebhookSubscription;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
|
||||
import Archive from "~/scenes/Archive";
|
||||
@@ -11,6 +12,8 @@ import CenteredContent from "~/components/CenteredContent";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import SocketProvider from "~/components/SocketProvider";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import { matchDocumentSlug as slug } from "~/utils/routeHelpers";
|
||||
|
||||
const SettingsRoutes = React.lazy(
|
||||
@@ -59,7 +62,10 @@ const RedirectDocument = ({
|
||||
/>
|
||||
);
|
||||
|
||||
export default function AuthenticatedRoutes() {
|
||||
function AuthenticatedRoutes() {
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
return (
|
||||
<SocketProvider>
|
||||
<Layout>
|
||||
@@ -71,14 +77,24 @@ export default function AuthenticatedRoutes() {
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
{can.createDocument && (
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
)}
|
||||
{can.createDocument && (
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
)}
|
||||
<Redirect from="/dashboard" to="/home" />
|
||||
<Route path="/home/:tab" component={Home} />
|
||||
<Route path="/home" component={Home} />
|
||||
<Route exact path="/templates" component={Templates} />
|
||||
<Route exact path="/templates/:sort" component={Templates} />
|
||||
<Route exact path="/drafts" component={Drafts} />
|
||||
<Route exact path="/archive" component={Archive} />
|
||||
<Route exact path="/trash" component={Trash} />
|
||||
<Redirect exact from="/starred" to="/home" />
|
||||
<Redirect exact from="/collections/*" to="/collection/*" />
|
||||
<Route exact path="/collection/:id/new" component={DocumentNew} />
|
||||
@@ -103,3 +119,5 @@ export default function AuthenticatedRoutes() {
|
||||
</SocketProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(AuthenticatedRoutes);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { CollectionValidation } from "@shared/validations";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import IconPicker from "~/components/IconPicker";
|
||||
@@ -93,6 +94,7 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={handleNameChange}
|
||||
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,6 +3,9 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, Trans, WithTranslation } from "react-i18next";
|
||||
import { randomElement } from "@shared/random";
|
||||
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";
|
||||
@@ -29,7 +32,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
icon = "";
|
||||
|
||||
@observable
|
||||
color = "#4E5C6E";
|
||||
color = randomElement(colorPalette);
|
||||
|
||||
@observable
|
||||
sharing = true;
|
||||
@@ -127,6 +130,7 @@ class CollectionNew extends React.Component<Props> {
|
||||
type="text"
|
||||
label={t("Name")}
|
||||
onChange={this.handleNameChange}
|
||||
maxLength={CollectionValidation.maxNameLength}
|
||||
value={this.name}
|
||||
required
|
||||
autoFocus
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
@@ -103,4 +104,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);
|
||||
|
||||
@@ -2,16 +2,21 @@ 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 { OfflineError } from "~/utils/errors";
|
||||
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";
|
||||
|
||||
@@ -73,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();
|
||||
@@ -105,7 +111,29 @@ function SharedDocumentScene(props: Props) {
|
||||
}, [documents, documentSlug, shareId, ui]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||
if (error instanceof OfflineError) {
|
||||
return <ErrorOffline />;
|
||||
} else if (error instanceof AuthorizationError) {
|
||||
setCookie("postLoginRedirectPath", props.location.pathname);
|
||||
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 />;
|
||||
}
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
@@ -137,4 +165,9 @@ function SharedDocumentScene(props: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const GetStarted = styled(Text)`
|
||||
text-align: center;
|
||||
margin-top: -8px;
|
||||
`;
|
||||
|
||||
export default observer(SharedDocumentScene);
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import invariant from "invariant";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { RouteComponentProps, StaticContext } from "react-router";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import ErrorOffline from "~/scenes/ErrorOffline";
|
||||
import withStores from "~/components/withStores";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { NotFoundError, OfflineError } from "~/utils/errors";
|
||||
import history from "~/utils/history";
|
||||
@@ -16,146 +14,104 @@ import { matchDocumentEdit } from "~/utils/routeHelpers";
|
||||
import HideSidebar from "./HideSidebar";
|
||||
import Loading from "./Loading";
|
||||
|
||||
type Props = RootStore &
|
||||
RouteComponentProps<
|
||||
{
|
||||
documentSlug: string;
|
||||
revisionId?: string;
|
||||
shareId?: string;
|
||||
title?: string;
|
||||
},
|
||||
StaticContext,
|
||||
{
|
||||
title?: string;
|
||||
}
|
||||
> & {
|
||||
children: (arg0: any) => React.ReactNode;
|
||||
};
|
||||
type Params = {
|
||||
documentSlug: string;
|
||||
revisionId?: string;
|
||||
shareId?: string;
|
||||
};
|
||||
|
||||
@observer
|
||||
class DataLoader extends React.Component<Props> {
|
||||
sharedTree: NavigationNode | null | undefined;
|
||||
type LocationState = {
|
||||
title?: string;
|
||||
restore?: boolean;
|
||||
revisionId?: string;
|
||||
};
|
||||
|
||||
@observable
|
||||
document: Document | null | undefined;
|
||||
type Children = (options: {
|
||||
document: Document;
|
||||
revision: Revision | undefined;
|
||||
abilities: Record<string, boolean>;
|
||||
isEditing: boolean;
|
||||
readOnly: boolean;
|
||||
onCreateLink: (title: string) => Promise<string>;
|
||||
sharedTree: NavigationNode | undefined;
|
||||
}) => React.ReactNode;
|
||||
|
||||
@observable
|
||||
revision: Revision | null | undefined;
|
||||
type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
|
||||
children: Children;
|
||||
};
|
||||
|
||||
@observable
|
||||
shapshot: Blob | null | undefined;
|
||||
function DataLoader({ match, children }: Props) {
|
||||
const { ui, shares, documents, auth, revisions } = useStores();
|
||||
const { team } = auth;
|
||||
const [error, setError] = React.useState<Error | null>(null);
|
||||
const { revisionId, shareId, documentSlug } = match.params;
|
||||
|
||||
@observable
|
||||
error: Error | null | undefined;
|
||||
// Allows loading by /doc/slug-<urlId> or /doc/<id>
|
||||
const document =
|
||||
documents.getByUrl(match.params.documentSlug) ??
|
||||
documents.get(match.params.documentSlug);
|
||||
|
||||
componentDidMount() {
|
||||
const { documents, match } = this.props;
|
||||
this.document = documents.getByUrl(match.params.documentSlug);
|
||||
this.sharedTree = this.document
|
||||
? documents.getSharedTree(this.document.id)
|
||||
: undefined;
|
||||
this.loadDocument();
|
||||
}
|
||||
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 location = useLocation<LocationState>();
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
// If we have the document in the store, but not it's policy then we need to
|
||||
// reload from the server otherwise the UI will not know which authorizations
|
||||
// the user has
|
||||
if (this.document) {
|
||||
const document = this.document;
|
||||
const policy = this.props.policies.get(document.id);
|
||||
|
||||
if (
|
||||
!policy &&
|
||||
!this.error &&
|
||||
this.props.auth.user &&
|
||||
this.props.auth.user.id
|
||||
) {
|
||||
this.loadDocument();
|
||||
}
|
||||
}
|
||||
|
||||
// Also need to load the revision if it changes
|
||||
const { revisionId } = this.props.match.params;
|
||||
|
||||
if (
|
||||
prevProps.match.params.revisionId !== revisionId &&
|
||||
revisionId &&
|
||||
revisionId !== "latest"
|
||||
) {
|
||||
this.loadRevision();
|
||||
}
|
||||
}
|
||||
|
||||
get isEditRoute() {
|
||||
return this.props.match.path === matchDocumentEdit;
|
||||
}
|
||||
|
||||
get isEditing() {
|
||||
return this.isEditRoute || this.props.auth?.team?.collaborativeEditing;
|
||||
}
|
||||
|
||||
onCreateLink = async (title: string) => {
|
||||
const document = this.document;
|
||||
invariant(document, "document must be loaded to create link");
|
||||
|
||||
const newDocument = await this.props.documents.create({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
title,
|
||||
text: "",
|
||||
});
|
||||
|
||||
return newDocument.url;
|
||||
};
|
||||
|
||||
loadRevision = async () => {
|
||||
const { revisionId } = this.props.match.params;
|
||||
|
||||
if (revisionId) {
|
||||
this.revision = await this.props.revisions.fetch(revisionId);
|
||||
}
|
||||
};
|
||||
|
||||
loadDocument = async () => {
|
||||
const { shareId, documentSlug, revisionId } = this.props.match.params;
|
||||
|
||||
// sets the document as active in the sidebar if we already have it loaded
|
||||
if (this.document) {
|
||||
this.props.ui.setActiveDocument(this.document);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.props.documents.fetchWithSharedTree(
|
||||
documentSlug,
|
||||
{
|
||||
React.useEffect(() => {
|
||||
async function fetchDocument() {
|
||||
try {
|
||||
await documents.fetchWithSharedTree(documentSlug, {
|
||||
shareId,
|
||||
}
|
||||
);
|
||||
this.sharedTree = response.sharedTree;
|
||||
this.document = response.document;
|
||||
|
||||
if (revisionId && revisionId !== "latest") {
|
||||
await this.loadRevision();
|
||||
} else {
|
||||
this.revision = undefined;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
} catch (err) {
|
||||
this.error = err;
|
||||
return;
|
||||
}
|
||||
fetchDocument();
|
||||
}, [ui, documents, document, shareId, documentSlug]);
|
||||
|
||||
const document = this.document;
|
||||
React.useEffect(() => {
|
||||
async function fetchRevision() {
|
||||
if (revisionId && revisionId !== "latest") {
|
||||
try {
|
||||
await revisions.fetch(revisionId);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
fetchRevision();
|
||||
}, [revisions, revisionId]);
|
||||
|
||||
const onCreateLink = React.useCallback(
|
||||
async (title: string) => {
|
||||
if (!document) {
|
||||
throw new Error("Document not loaded yet");
|
||||
}
|
||||
|
||||
const newDocument = await documents.create({
|
||||
collectionId: document.collectionId,
|
||||
parentDocumentId: document.parentDocumentId,
|
||||
title,
|
||||
text: "",
|
||||
});
|
||||
|
||||
return newDocument.url;
|
||||
},
|
||||
[document, documents]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (document) {
|
||||
const can = this.props.policies.abilities(document.id);
|
||||
// sets the document as active in the sidebar, ideally in the future this
|
||||
// will be route driven.
|
||||
this.props.ui.setActiveDocument(document);
|
||||
// sets the current document as active in the sidebar
|
||||
ui.setActiveDocument(document);
|
||||
|
||||
// If we're attempting to update an archived, deleted, or otherwise
|
||||
// uneditable document then forward to the canonical read url.
|
||||
if (!can.update && this.isEditRoute) {
|
||||
if (!can.update && isEditRoute) {
|
||||
history.push(document.url);
|
||||
return;
|
||||
}
|
||||
@@ -163,72 +119,51 @@ class DataLoader extends React.Component<Props> {
|
||||
// Prevents unauthorized request to load share information for the document
|
||||
// when viewing a public share link
|
||||
if (can.read) {
|
||||
this.props.shares.fetch(document.id).catch((err) => {
|
||||
shares.fetch(document.id).catch((err) => {
|
||||
if (!(err instanceof NotFoundError)) {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [can.read, can.update, document, isEditRoute, shares, ui]);
|
||||
|
||||
render() {
|
||||
const { location, policies, auth, match, ui } = this.props;
|
||||
const { revisionId } = match.params;
|
||||
|
||||
if (this.error) {
|
||||
return this.error instanceof OfflineError ? (
|
||||
<ErrorOffline />
|
||||
) : (
|
||||
<Error404 />
|
||||
);
|
||||
}
|
||||
|
||||
const team = auth.team;
|
||||
const document = this.document;
|
||||
const revision = this.revision;
|
||||
|
||||
if (!document || !team || (revisionId && !revision)) {
|
||||
return (
|
||||
<>
|
||||
<Loading location={location} />
|
||||
{this.isEditing && !team?.collaborativeEditing && (
|
||||
<HideSidebar ui={ui} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const abilities = policies.abilities(document.id);
|
||||
// We do not want to remount the document when changing from view->edit
|
||||
// on the multiplayer flag as the doc is guaranteed to be upto date.
|
||||
const key = team.collaborativeEditing
|
||||
? ""
|
||||
: this.isEditing
|
||||
? "editing"
|
||||
: "read-only";
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||
}
|
||||
|
||||
if (!document || !team || (revisionId && !revision)) {
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{this.isEditing && !team.collaborativeEditing && (
|
||||
<HideSidebar ui={ui} />
|
||||
)}
|
||||
{this.props.children({
|
||||
document,
|
||||
revision,
|
||||
abilities,
|
||||
isEditing: this.isEditing,
|
||||
readOnly:
|
||||
!this.isEditing ||
|
||||
!abilities.update ||
|
||||
document.isArchived ||
|
||||
!!revisionId,
|
||||
onCreateLink: this.onCreateLink,
|
||||
sharedTree: this.sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
<>
|
||||
<Loading location={location} />
|
||||
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// We do not want to remount the document when changing from view->edit
|
||||
// on the multiplayer flag as the doc is guaranteed to be upto date.
|
||||
const key = team.collaborativeEditing
|
||||
? ""
|
||||
: isEditing
|
||||
? "editing"
|
||||
: "read-only";
|
||||
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{isEditing && !team.collaborativeEditing && <HideSidebar ui={ui} />}
|
||||
{children({
|
||||
document,
|
||||
revision,
|
||||
abilities: can,
|
||||
isEditing,
|
||||
readOnly:
|
||||
!isEditing || !can.update || document.isArchived || !!revisionId,
|
||||
onCreateLink,
|
||||
sharedTree,
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
export default withStores(DataLoader);
|
||||
export default observer(DataLoader);
|
||||
|
||||
@@ -55,13 +55,21 @@ import References from "./References";
|
||||
|
||||
const AUTOSAVE_DELAY = 3000;
|
||||
|
||||
type Params = {
|
||||
documentSlug: string;
|
||||
revisionId?: string;
|
||||
shareId?: string;
|
||||
};
|
||||
|
||||
type LocationState = {
|
||||
title?: string;
|
||||
restore?: boolean;
|
||||
revisionId?: string;
|
||||
};
|
||||
|
||||
type Props = WithTranslation &
|
||||
RootStore &
|
||||
RouteComponentProps<
|
||||
Record<string, string>,
|
||||
StaticContext,
|
||||
{ restore?: boolean; revisionId?: string }
|
||||
> & {
|
||||
RouteComponentProps<Params, StaticContext, LocationState> & {
|
||||
sharedTree?: NavigationNode;
|
||||
abilities: Record<string, any>;
|
||||
document: 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}
|
||||
|
||||
@@ -57,8 +57,10 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
}
|
||||
}, [ref]);
|
||||
|
||||
// Save document when blurring title, but delay so that if clicking on a
|
||||
// button this is allowed to execute first.
|
||||
const handleBlur = React.useCallback(() => {
|
||||
props.onSave({ autosave: true });
|
||||
setTimeout(() => props.onSave({ autosave: true }), 250);
|
||||
}, [props]);
|
||||
|
||||
const handleGoToNextInput = React.useCallback(
|
||||
|
||||
@@ -7,6 +7,7 @@ import Document from "~/models/Document";
|
||||
import Button from "~/components/Button";
|
||||
import Popover from "~/components/Popover";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import SharePopover from "./SharePopover";
|
||||
|
||||
@@ -17,10 +18,12 @@ type Props = {
|
||||
function ShareButton({ document }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { shares } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const share = shares.getByDocumentId(document.id);
|
||||
const sharedParent = shares.getByDocumentParents(document.id);
|
||||
const isPubliclyShared =
|
||||
share?.published || (sharedParent?.published && !document.isDraft);
|
||||
team.sharing &&
|
||||
(share?.published || (sharedParent?.published && !document.isDraft));
|
||||
|
||||
const popover = usePopoverState({
|
||||
gutter: 0,
|
||||
|
||||
@@ -14,6 +14,7 @@ import Input from "~/components/Input";
|
||||
import Notice from "~/components/Notice";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -36,8 +37,9 @@ function SharePopover({
|
||||
onRequestClose,
|
||||
visible,
|
||||
}: Props) {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { shares, auth } = useStores();
|
||||
const { shares } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const [isCopied, setIsCopied] = React.useState(false);
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
@@ -47,22 +49,23 @@ function SharePopover({
|
||||
const canPublish =
|
||||
can.update &&
|
||||
!document.isTemplate &&
|
||||
auth.team?.sharing &&
|
||||
team.sharing &&
|
||||
documentAbilities.share;
|
||||
const isPubliclyShared =
|
||||
(share && share.published) ||
|
||||
(sharedParent && sharedParent.published && !document.isDraft);
|
||||
team.sharing &&
|
||||
((share && share.published) ||
|
||||
(sharedParent && sharedParent.published && !document.isDraft));
|
||||
|
||||
useKeyDown("Escape", onRequestClose);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visible) {
|
||||
if (visible && team.sharing) {
|
||||
document.share();
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
|
||||
return () => (timeout.current ? clearTimeout(timeout.current) : undefined);
|
||||
}, [document, visible]);
|
||||
}, [document, visible, team.sharing]);
|
||||
|
||||
const handlePublishedChange = React.useCallback(
|
||||
async (event) => {
|
||||
@@ -113,6 +116,9 @@ function SharePopover({
|
||||
|
||||
const userLocale = useUserLocale();
|
||||
const locale = userLocale ? dateLocale(userLocale) : undefined;
|
||||
const shareUrl = team.sharing
|
||||
? share?.url ?? ""
|
||||
: `${team.url}${document.url}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -199,14 +205,14 @@ function SharePopover({
|
||||
type="text"
|
||||
label={t("Link")}
|
||||
placeholder={`${t("Loading")}…`}
|
||||
value={share ? share.url : ""}
|
||||
value={shareUrl}
|
||||
labelHidden
|
||||
readOnly
|
||||
/>
|
||||
<CopyToClipboard text={share ? share.url : ""} onCopy={handleCopied}>
|
||||
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCopied || !share}
|
||||
disabled={isCopied || (!share && team.sharing)}
|
||||
ref={buttonRef}
|
||||
primary
|
||||
>
|
||||
|
||||
@@ -7,13 +7,21 @@ import DataLoader from "./components/DataLoader";
|
||||
import Document from "./components/Document";
|
||||
import SocketPresence from "./components/SocketPresence";
|
||||
|
||||
export default function DocumentScene(
|
||||
props: RouteComponentProps<
|
||||
{ documentSlug: string; revisionId: string },
|
||||
StaticContext,
|
||||
{ title?: string }
|
||||
>
|
||||
) {
|
||||
type Params = {
|
||||
documentSlug: string;
|
||||
revisionId?: string;
|
||||
shareId?: string;
|
||||
};
|
||||
|
||||
type LocationState = {
|
||||
title?: string;
|
||||
restore?: boolean;
|
||||
revisionId?: string;
|
||||
};
|
||||
|
||||
type Props = RouteComponentProps<Params, StaticContext, LocationState>;
|
||||
|
||||
export default function DocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { documentSlug, revisionId } = props.match.params;
|
||||
@@ -47,12 +55,12 @@ export default function DocumentScene(
|
||||
if (isActive && !isMultiplayer) {
|
||||
return (
|
||||
<SocketPresence documentId={document.id} isEditing={isEditing}>
|
||||
<Document document={document} match={props.match} {...rest} />
|
||||
<Document document={document} {...rest} />
|
||||
</SocketPresence>
|
||||
);
|
||||
}
|
||||
|
||||
return <Document document={document} match={props.match} {...rest} />;
|
||||
return <Document document={document} {...rest} />;
|
||||
}}
|
||||
</DataLoader>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -25,7 +25,7 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
|
||||
try {
|
||||
await group.save({
|
||||
name: name,
|
||||
name,
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,13 @@ export default function Notices() {
|
||||
invite email.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "gmail-account-creation" && (
|
||||
<NoticeAlert>
|
||||
Sorry, a new account cannot be created with a personal Gmail address.
|
||||
<hr />
|
||||
Please use a Google Workspaces account instead.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "maximum-teams" && (
|
||||
<NoticeAlert>
|
||||
The team you authenticated with is not authorized on this
|
||||
@@ -50,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 && (
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -44,6 +44,20 @@ function Notifications() {
|
||||
"Receive a notification whenever a new collection is created"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: "emails.invite_accepted",
|
||||
title: t("Invite accepted"),
|
||||
description: t(
|
||||
"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",
|
||||
|
||||
+125
-104
@@ -36,9 +36,17 @@ function Security() {
|
||||
defaultUserRole: team.defaultUserRole,
|
||||
memberCollectionCreate: team.memberCollectionCreate,
|
||||
inviteRequired: team.inviteRequired,
|
||||
allowedDomains: team.allowedDomains,
|
||||
});
|
||||
|
||||
const [allowedDomains, setAllowedDomains] = useState([
|
||||
...(team.allowedDomains ?? []),
|
||||
]);
|
||||
const [lastKnownDomainCount, updateLastKnownDomainCount] = useState(
|
||||
allowedDomains.length
|
||||
);
|
||||
|
||||
const [existingDomainsTouched, setExistingDomainsTouched] = useState(false);
|
||||
|
||||
const authenticationMethods = team.signinMethods;
|
||||
|
||||
const showSuccessMessage = React.useMemo(
|
||||
@@ -51,17 +59,13 @@ function Security() {
|
||||
[showToast, t]
|
||||
);
|
||||
|
||||
const [domainsChanged, setDomainsChanged] = useState(false);
|
||||
|
||||
const saveData = React.useCallback(
|
||||
async (newData) => {
|
||||
try {
|
||||
setData(newData);
|
||||
await auth.updateTeam(newData);
|
||||
showSuccessMessage();
|
||||
setDomainsChanged(false);
|
||||
} catch (err) {
|
||||
setDomainsChanged(true);
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
@@ -77,6 +81,21 @@ function Security() {
|
||||
[data, saveData]
|
||||
);
|
||||
|
||||
const handleSaveDomains = React.useCallback(async () => {
|
||||
try {
|
||||
await auth.updateTeam({
|
||||
allowedDomains,
|
||||
});
|
||||
showSuccessMessage();
|
||||
setExistingDomainsTouched(false);
|
||||
updateLastKnownDomainCount(allowedDomains.length);
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}, [auth, allowedDomains, showSuccessMessage, showToast]);
|
||||
|
||||
const handleDefaultRoleChange = React.useCallback(
|
||||
async (newDefaultRole: string) => {
|
||||
await saveData({ ...data, defaultUserRole: newDefaultRole });
|
||||
@@ -84,26 +103,26 @@ function Security() {
|
||||
[data, saveData]
|
||||
);
|
||||
|
||||
const handleAllowSignupsChange = React.useCallback(
|
||||
const handleInviteRequiredChange = React.useCallback(
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inviteRequired = !ev.target.checked;
|
||||
const inviteRequired = ev.target.checked;
|
||||
const newData = { ...data, inviteRequired };
|
||||
|
||||
if (inviteRequired) {
|
||||
dialogs.openModal({
|
||||
isCentered: true,
|
||||
title: t("Are you sure you want to disable authorized signups?"),
|
||||
title: t("Are you sure you want to require invites?"),
|
||||
content: (
|
||||
<ConfirmationDialog
|
||||
onSubmit={async () => {
|
||||
await saveData(newData);
|
||||
}}
|
||||
submitText={t("I’m sure — Disable")}
|
||||
savingText={`${t("Disabling")}…`}
|
||||
submitText={t("I’m sure")}
|
||||
savingText={`${t("Saving")}…`}
|
||||
danger
|
||||
>
|
||||
<Trans
|
||||
defaults="New account creation using <em>{{ authenticationMethods }}</em> will be disabled. New users will need to be invited."
|
||||
defaults="New users will first need to be invited to create an account. <em>Default role</em> and <em>Allowed domains</em> will no longer apply."
|
||||
values={{
|
||||
authenticationMethods,
|
||||
}}
|
||||
@@ -123,34 +142,41 @@ function Security() {
|
||||
);
|
||||
|
||||
const handleRemoveDomain = async (index: number) => {
|
||||
const newData = {
|
||||
...data,
|
||||
};
|
||||
newData.allowedDomains && newData.allowedDomains.splice(index, 1);
|
||||
const newDomains = allowedDomains.filter((_, i) => index !== i);
|
||||
|
||||
setData(newData);
|
||||
setDomainsChanged(true);
|
||||
setAllowedDomains(newDomains);
|
||||
|
||||
const touchedExistingDomain = index < lastKnownDomainCount;
|
||||
if (touchedExistingDomain) {
|
||||
setExistingDomainsTouched(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDomain = () => {
|
||||
const newData = {
|
||||
...data,
|
||||
allowedDomains: [...(data.allowedDomains || []), ""],
|
||||
};
|
||||
const newDomains = [...allowedDomains, ""];
|
||||
|
||||
setData(newData);
|
||||
setAllowedDomains(newDomains);
|
||||
};
|
||||
|
||||
const createOnDomainChangedHandler = (index: number) => (
|
||||
ev: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const newData = { ...data };
|
||||
const newDomains = allowedDomains.slice();
|
||||
|
||||
newData.allowedDomains![index] = ev.currentTarget.value;
|
||||
setData(newData);
|
||||
setDomainsChanged(true);
|
||||
newDomains[index] = ev.currentTarget.value;
|
||||
setAllowedDomains(newDomains);
|
||||
|
||||
const touchedExistingDomain = index < lastKnownDomainCount;
|
||||
if (touchedExistingDomain) {
|
||||
setExistingDomainsTouched(true);
|
||||
}
|
||||
};
|
||||
|
||||
const showSaveChanges =
|
||||
existingDomainsTouched ||
|
||||
allowedDomains.filter((value: string) => value !== "").length > // New domains were added
|
||||
lastKnownDomainCount;
|
||||
|
||||
return (
|
||||
<Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}>
|
||||
<Heading>{t("Security")}</Heading>
|
||||
@@ -214,63 +240,57 @@ function Security() {
|
||||
</SettingRow>
|
||||
{isCloudHosted && (
|
||||
<SettingRow
|
||||
label={t("Allow authorized signups")}
|
||||
name="allowSignups"
|
||||
description={
|
||||
<Trans
|
||||
defaults="Allow authorized <em>{{ authenticationMethods }}</em> users to create new accounts without first receiving an invite"
|
||||
values={{
|
||||
authenticationMethods,
|
||||
}}
|
||||
components={{
|
||||
em: <strong />,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={t("Require invites")}
|
||||
name="inviteRequired"
|
||||
description={t(
|
||||
"Require members to be invited to the team before they can create an account using SSO."
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
id="allowSignups"
|
||||
checked={!data.inviteRequired}
|
||||
onChange={handleAllowSignupsChange}
|
||||
id="inviteRequired"
|
||||
checked={data.inviteRequired}
|
||||
onChange={handleInviteRequiredChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
label={t("Default role")}
|
||||
name="defaultUserRole"
|
||||
description={t(
|
||||
"The default user role for new accounts. Changing this setting does not affect existing user accounts."
|
||||
)}
|
||||
>
|
||||
<InputSelect
|
||||
id="defaultUserRole"
|
||||
value={data.defaultUserRole}
|
||||
options={[
|
||||
{
|
||||
label: t("Member"),
|
||||
value: "member",
|
||||
},
|
||||
{
|
||||
label: t("Viewer"),
|
||||
value: "viewer",
|
||||
},
|
||||
]}
|
||||
onChange={handleDefaultRoleChange}
|
||||
ariaLabel={t("Default role")}
|
||||
short
|
||||
/>
|
||||
</SettingRow>
|
||||
{!data.inviteRequired && (
|
||||
<SettingRow
|
||||
label={t("Default role")}
|
||||
name="defaultUserRole"
|
||||
description={t(
|
||||
"The default user role for new accounts. Changing this setting does not affect existing user accounts."
|
||||
)}
|
||||
>
|
||||
<InputSelect
|
||||
id="defaultUserRole"
|
||||
value={data.defaultUserRole}
|
||||
options={[
|
||||
{
|
||||
label: t("Member"),
|
||||
value: "member",
|
||||
},
|
||||
{
|
||||
label: t("Viewer"),
|
||||
value: "viewer",
|
||||
},
|
||||
]}
|
||||
onChange={handleDefaultRoleChange}
|
||||
ariaLabel={t("Default role")}
|
||||
short
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
|
||||
<SettingRow
|
||||
label={t("Allowed Domains")}
|
||||
name="allowedDomains"
|
||||
description={t(
|
||||
"The domains which should be allowed to create accounts. This applies to both SSO and Email logins. Changing this setting does not affect existing user accounts."
|
||||
)}
|
||||
>
|
||||
{data.allowedDomains &&
|
||||
data.allowedDomains.map((domain, index) => (
|
||||
{!data.inviteRequired && (
|
||||
<SettingRow
|
||||
label={t("Allowed domains")}
|
||||
name="allowedDomains"
|
||||
description={t(
|
||||
"The domains which should be allowed to create new accounts using SSO. Changing this setting does not affect existing user accounts."
|
||||
)}
|
||||
>
|
||||
{allowedDomains.map((domain, index) => (
|
||||
<Flex key={index} gap={4}>
|
||||
<Input
|
||||
key={index}
|
||||
@@ -292,35 +312,36 @@ function Security() {
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
|
||||
{!data.allowedDomains?.length ||
|
||||
data.allowedDomains[data.allowedDomains.length - 1] !== "" ? (
|
||||
<Fade>
|
||||
<Button type="button" onClick={handleAddDomain} neutral>
|
||||
{data.allowedDomains?.length ? (
|
||||
<Trans>Add another</Trans>
|
||||
) : (
|
||||
<Trans>Add a domain</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</Fade>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
|
||||
{!allowedDomains.length ||
|
||||
allowedDomains[allowedDomains.length - 1] !== "" ? (
|
||||
<Fade>
|
||||
<Button type="button" onClick={handleAddDomain} neutral>
|
||||
{allowedDomains.length ? (
|
||||
<Trans>Add another</Trans>
|
||||
) : (
|
||||
<Trans>Add a domain</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</Fade>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{domainsChanged && (
|
||||
<Fade>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleChange}
|
||||
disabled={auth.isSaving}
|
||||
>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
</Fade>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
{showSaveChanges && (
|
||||
<Fade>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleSaveDomains}
|
||||
disabled={auth.isSaving}
|
||||
>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
</Fade>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
)}
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { WebhooksIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Button from "~/components/Button";
|
||||
import Heading from "~/components/Heading";
|
||||
import Modal from "~/components/Modal";
|
||||
import PaginatedList from "~/components/PaginatedList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import Text from "~/components/Text";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import WebhookSubscriptionListItem from "./components/WebhookSubscriptionListItem";
|
||||
import WebhookSubscriptionNew from "./components/WebhookSubscriptionNew";
|
||||
|
||||
function Webhooks() {
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { webhookSubscriptions } = useStores();
|
||||
const [newModalOpen, handleNewModalOpen, handleNewModalClose] = useBoolean();
|
||||
const can = usePolicy(team.id);
|
||||
|
||||
return (
|
||||
<Scene
|
||||
title={t("Webhooks")}
|
||||
icon={<WebhooksIcon color="currentColor" />}
|
||||
actions={
|
||||
<>
|
||||
{can.createWebhookSubscription && (
|
||||
<Action>
|
||||
<Button
|
||||
type="submit"
|
||||
value={`${t("New webhook")}…`}
|
||||
onClick={handleNewModalOpen}
|
||||
/>
|
||||
</Action>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Heading>{t("Webhooks")}</Heading>
|
||||
<Text type="secondary">
|
||||
<Trans defaults="Webhooks can be used to notify your application when events happen in Outline. Events are sent as a https request with a JSON payload in near real-time." />
|
||||
</Text>
|
||||
<PaginatedList
|
||||
fetch={webhookSubscriptions.fetchPage}
|
||||
items={webhookSubscriptions.orderedData}
|
||||
heading={<Subheading sticky>{t("Webhooks")}</Subheading>}
|
||||
renderItem={(webhook: WebhookSubscription) => (
|
||||
<WebhookSubscriptionListItem key={webhook.id} webhook={webhook} />
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Create a webhook")}
|
||||
onRequestClose={handleNewModalClose}
|
||||
isOpen={newModalOpen}
|
||||
>
|
||||
<WebhookSubscriptionNew onSubmit={handleNewModalClose} />
|
||||
</Modal>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Webhooks);
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { compact } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FilterOptions from "~/components/FilterOptions";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
|
||||
type Props = {
|
||||
activeKey: string;
|
||||
@@ -9,34 +12,41 @@ type Props = {
|
||||
|
||||
const UserStatusFilter = ({ activeKey, onSelect, ...rest }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const options = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "",
|
||||
label: t("Active"),
|
||||
},
|
||||
{
|
||||
key: "all",
|
||||
label: t("Everyone"),
|
||||
},
|
||||
{
|
||||
key: "admins",
|
||||
label: t("Admins"),
|
||||
},
|
||||
{
|
||||
key: "suspended",
|
||||
label: t("Suspended"),
|
||||
},
|
||||
{
|
||||
key: "invited",
|
||||
label: t("Invited"),
|
||||
},
|
||||
{
|
||||
key: "viewers",
|
||||
label: t("Viewers"),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
() =>
|
||||
compact([
|
||||
{
|
||||
key: "",
|
||||
label: t("Active"),
|
||||
},
|
||||
{
|
||||
key: "all",
|
||||
label: t("Everyone"),
|
||||
},
|
||||
{
|
||||
key: "admins",
|
||||
label: t("Admins"),
|
||||
},
|
||||
...(user.isAdmin
|
||||
? [
|
||||
{
|
||||
key: "suspended",
|
||||
label: t("Suspended"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "invited",
|
||||
label: t("Invited"),
|
||||
},
|
||||
{
|
||||
key: "viewers",
|
||||
label: t("Viewers"),
|
||||
},
|
||||
]),
|
||||
[t, user.isAdmin]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -50,4 +60,4 @@ const UserStatusFilter = ({ activeKey, onSelect, ...rest }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default UserStatusFilter;
|
||||
export default observer(UserStatusFilter);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
|
||||
type Props = {
|
||||
webhook: WebhookSubscription;
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
export default function WebhookSubscriptionRevokeDialog({
|
||||
webhook,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await webhook.delete();
|
||||
onSubmit();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
{t("Are you sure you want to delete the {{ name }} webhook?", {
|
||||
name: webhook.name,
|
||||
})}
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import WebhookSubscriptionForm from "./WebhookSubscriptionForm";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
webhookSubscription: WebhookSubscription;
|
||||
};
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
function WebhookSubscriptionEdit({ onSubmit, webhookSubscription }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
const events = Array.isArray(data.events) ? data.events : [data.events];
|
||||
|
||||
const toSend = {
|
||||
...data,
|
||||
events,
|
||||
};
|
||||
|
||||
await webhookSubscription.save(toSend);
|
||||
|
||||
showToast(
|
||||
t("Webhook updated", {
|
||||
type: "success",
|
||||
})
|
||||
);
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[t, showToast, onSubmit, webhookSubscription]
|
||||
);
|
||||
|
||||
return (
|
||||
<WebhookSubscriptionForm
|
||||
handleSubmit={handleSubmit}
|
||||
webhookSubscription={webhookSubscription}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebhookSubscriptionEdit;
|
||||
@@ -0,0 +1,286 @@
|
||||
import { isEqual, filter, includes } from "lodash";
|
||||
import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import Button from "~/components/Button";
|
||||
import { ReactHookWrappedInput } from "~/components/Input";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
|
||||
const WEBHOOK_EVENTS = {
|
||||
user: [
|
||||
"users.create",
|
||||
"users.signin",
|
||||
"users.update",
|
||||
"users.suspend",
|
||||
"users.activate",
|
||||
"users.delete",
|
||||
"users.invite",
|
||||
"users.promote",
|
||||
"users.demote",
|
||||
],
|
||||
document: [
|
||||
"documents.create",
|
||||
"documents.publish",
|
||||
"documents.unpublish",
|
||||
"documents.delete",
|
||||
"documents.permanent_delete",
|
||||
"documents.archive",
|
||||
"documents.unarchive",
|
||||
"documents.restore",
|
||||
"documents.star",
|
||||
"documents.unstar",
|
||||
"documents.move",
|
||||
"documents.update",
|
||||
"documents.update.delayed",
|
||||
"documents.update.debounced",
|
||||
"documents.title_change",
|
||||
],
|
||||
revision: ["revisions.create"],
|
||||
fileOperation: [
|
||||
"file_operations.create",
|
||||
"file_operations.update",
|
||||
"file_operations.delete",
|
||||
],
|
||||
collection: [
|
||||
"collections.create",
|
||||
"collections.update",
|
||||
"collections.delete",
|
||||
"collections.add_user",
|
||||
"collections.remove_user",
|
||||
"collections.add_group",
|
||||
"collections.remove_group",
|
||||
"collections.move",
|
||||
"collections.permission_changed",
|
||||
],
|
||||
group: [
|
||||
"groups.create",
|
||||
"groups.update",
|
||||
"groups.delete",
|
||||
"groups.add_user",
|
||||
"groups.remove_user",
|
||||
],
|
||||
integration: ["integrations.create", "integrations.update"],
|
||||
share: ["shares.create", "shares.update", "shares.revoke"],
|
||||
team: ["teams.update"],
|
||||
pin: ["pins.create", "pins.update", "pins.delete"],
|
||||
webhookSubscription: [
|
||||
"webhook_subscriptions.create",
|
||||
"webhook_subscriptions.delete",
|
||||
"webhook_subscriptions.update",
|
||||
],
|
||||
view: ["views.create"],
|
||||
};
|
||||
|
||||
const EventCheckboxLabel = styled.label`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px;
|
||||
padding: 0.2em 0;
|
||||
`;
|
||||
|
||||
const GroupEventCheckboxLabel = styled(EventCheckboxLabel)`
|
||||
font-weight: 500;
|
||||
font-size: 1.2em;
|
||||
`;
|
||||
|
||||
const AllEventCheckboxLabel = styled(GroupEventCheckboxLabel)`
|
||||
font-size: 1.4em;
|
||||
`;
|
||||
|
||||
const EventCheckboxText = styled.span`
|
||||
margin-left: 0.5rem;
|
||||
`;
|
||||
|
||||
interface FieldProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
const FieldSet = styled.fieldset<FieldProps>`
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
|
||||
${({ disabled }) =>
|
||||
disabled &&
|
||||
`
|
||||
opacity: 0.5;
|
||||
`}
|
||||
`;
|
||||
|
||||
interface MobileProps {
|
||||
isMobile?: boolean;
|
||||
}
|
||||
const GroupGrid = styled.div<MobileProps>`
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
${({ isMobile }) =>
|
||||
isMobile &&
|
||||
`
|
||||
grid-template-columns: 1fr;
|
||||
`}
|
||||
`;
|
||||
|
||||
const GroupWrapper = styled.div<MobileProps>`
|
||||
padding-bottom: 2rem;
|
||||
|
||||
${({ isMobile }) =>
|
||||
isMobile &&
|
||||
`
|
||||
padding-bottom: 1rem;
|
||||
`}
|
||||
`;
|
||||
|
||||
const TextFields = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1em;
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
handleSubmit: (data: FormData) => void;
|
||||
webhookSubscription?: WebhookSubscription;
|
||||
};
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
register,
|
||||
handleSubmit: formHandleSubmit,
|
||||
formState,
|
||||
watch,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
mode: "all",
|
||||
defaultValues: {
|
||||
events: webhookSubscription ? [...webhookSubscription.events] : [],
|
||||
name: webhookSubscription?.name,
|
||||
url: webhookSubscription?.url,
|
||||
},
|
||||
});
|
||||
|
||||
const events = watch("events");
|
||||
const selectedGroups = filter(events, (e) => !e.includes("."));
|
||||
const isAllEventSelected = includes(events, "*");
|
||||
const filteredEvents = filter(events, (e) => {
|
||||
const [beforePeriod] = e.split(".");
|
||||
|
||||
return (
|
||||
selectedGroups.length === 0 ||
|
||||
e === beforePeriod ||
|
||||
!selectedGroups.includes(beforePeriod)
|
||||
);
|
||||
});
|
||||
|
||||
const isMobile = useMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAllEventSelected) {
|
||||
setValue("events", ["*"]);
|
||||
}
|
||||
}, [isAllEventSelected, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEqual(events, filteredEvents)) {
|
||||
setValue("events", filteredEvents);
|
||||
}
|
||||
}, [events, filteredEvents, setValue]);
|
||||
|
||||
const verb = webhookSubscription ? t("Update") : t("Create");
|
||||
const inProgressVerb = webhookSubscription ? t("Updating") : t("Creating");
|
||||
|
||||
function EventCheckbox({ label, value }: { label: string; value: string }) {
|
||||
const LabelComponent =
|
||||
value === "*"
|
||||
? AllEventCheckboxLabel
|
||||
: Object.keys(WEBHOOK_EVENTS).includes(value)
|
||||
? GroupEventCheckboxLabel
|
||||
: EventCheckboxLabel;
|
||||
|
||||
return (
|
||||
<LabelComponent>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultValue={value}
|
||||
{...register("events", {})}
|
||||
/>
|
||||
<EventCheckboxText>{label}</EventCheckboxText>
|
||||
</LabelComponent>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={formHandleSubmit(handleSubmit)}>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Provide a descriptive name for this webhook and the URL we should send
|
||||
a POST request to when matching events are created.
|
||||
</Trans>
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Subscribe to all events, groups, or individual events. We recommend
|
||||
only subscribing to the minimum amount of events that your application
|
||||
needs to function.
|
||||
</Trans>
|
||||
</Text>
|
||||
<TextFields>
|
||||
<ReactHookWrappedInput
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
label={t("Name")}
|
||||
{...register("name", {
|
||||
required: true,
|
||||
})}
|
||||
/>
|
||||
<ReactHookWrappedInput
|
||||
required
|
||||
autoFocus
|
||||
flex
|
||||
pattern="https://.*"
|
||||
placeholder="https://…"
|
||||
label={t("URL")}
|
||||
{...register("url", { required: true })}
|
||||
/>
|
||||
</TextFields>
|
||||
|
||||
<EventCheckbox label={t("All events")} value="*" />
|
||||
|
||||
<FieldSet disabled={isAllEventSelected}>
|
||||
<GroupGrid isMobile={isMobile}>
|
||||
{Object.entries(WEBHOOK_EVENTS).map(([group, events], i) => (
|
||||
<GroupWrapper key={i} isMobile={isMobile}>
|
||||
<EventCheckbox
|
||||
label={t(`All {{ groupName }} events`, { groupName: group })}
|
||||
value={group}
|
||||
/>
|
||||
<FieldSet disabled={selectedGroups.includes(group)}>
|
||||
{events.map((event) => (
|
||||
<EventCheckbox label={event} value={event} key={event} />
|
||||
))}
|
||||
</FieldSet>
|
||||
</GroupWrapper>
|
||||
))}
|
||||
</GroupGrid>
|
||||
</FieldSet>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting || !formState.isValid}
|
||||
>
|
||||
{formState.isSubmitting ? `${inProgressVerb}…` : verb}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebhookSubscriptionForm;
|
||||
@@ -0,0 +1,89 @@
|
||||
import { EditIcon, TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import Badge from "~/components/Badge";
|
||||
import Button from "~/components/Button";
|
||||
import ListItem from "~/components/List/Item";
|
||||
import Modal from "~/components/Modal";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import WebhookSubscriptionRevokeDialog from "./WebhookSubscriptionDeleteDialog";
|
||||
import WebhookSubscriptionEdit from "./WebhookSubscriptionEdit";
|
||||
|
||||
type Props = {
|
||||
webhook: WebhookSubscription;
|
||||
};
|
||||
|
||||
const WebhookSubscriptionListItem = ({ webhook }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { dialogs } = useStores();
|
||||
const [
|
||||
editModalOpen,
|
||||
handleEditModalOpen,
|
||||
handleEditModalClose,
|
||||
] = useBoolean();
|
||||
|
||||
const showDeletionConfirmation = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete webhook"),
|
||||
isCentered: true,
|
||||
content: (
|
||||
<WebhookSubscriptionRevokeDialog
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
webhook={webhook}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [t, dialogs, webhook]);
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={webhook.id}
|
||||
title={
|
||||
<>
|
||||
{webhook.name}
|
||||
{!webhook.enabled && (
|
||||
<StyledBadge yellow={true}>{t("Disabled")}</StyledBadge>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
subtitle={
|
||||
<>
|
||||
{t("Subscribed events")}: <code>{webhook.events.join(", ")}</code>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
onClick={showDeletionConfirmation}
|
||||
icon={<TrashIcon />}
|
||||
neutral
|
||||
>
|
||||
{t("Delete")}
|
||||
</Button>
|
||||
<Button icon={<EditIcon />} onClick={handleEditModalOpen} neutral>
|
||||
{t("Edit")}
|
||||
</Button>
|
||||
<Modal
|
||||
title={t("Edit webhook")}
|
||||
onRequestClose={handleEditModalClose}
|
||||
isOpen={editModalOpen}
|
||||
>
|
||||
<WebhookSubscriptionEdit
|
||||
onSubmit={handleEditModalClose}
|
||||
webhookSubscription={webhook}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledBadge = styled(Badge)`
|
||||
position: absolute;
|
||||
`;
|
||||
|
||||
export default WebhookSubscriptionListItem;
|
||||
@@ -0,0 +1,51 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import WebhookSubscriptionForm from "./WebhookSubscriptionForm";
|
||||
|
||||
type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
interface FormData {
|
||||
name: string;
|
||||
url: string;
|
||||
events: string[];
|
||||
}
|
||||
|
||||
function WebhookSubscriptionNew({ onSubmit }: Props) {
|
||||
const { webhookSubscriptions } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (data: FormData) => {
|
||||
try {
|
||||
const events = Array.isArray(data.events) ? data.events : [data.events];
|
||||
|
||||
const toSend = {
|
||||
...data,
|
||||
events,
|
||||
};
|
||||
|
||||
await webhookSubscriptions.create(toSend);
|
||||
showToast(
|
||||
t("Webhook created", {
|
||||
type: "success",
|
||||
})
|
||||
);
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
[t, showToast, onSubmit, webhookSubscriptions]
|
||||
);
|
||||
|
||||
return <WebhookSubscriptionForm handleSubmit={handleSubmit} />;
|
||||
}
|
||||
|
||||
export default WebhookSubscriptionNew;
|
||||
+16
-1
@@ -2,18 +2,33 @@ import { observer } from "mobx-react";
|
||||
import { TrashIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Action } from "~/components/Actions";
|
||||
import Empty from "~/components/Empty";
|
||||
import Heading from "~/components/Heading";
|
||||
import PaginatedDocumentList from "~/components/PaginatedDocumentList";
|
||||
import Scene from "~/components/Scene";
|
||||
import Subheading from "~/components/Subheading";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import EmptyTrashMenu from "~/menus/EmptyTrashMenu";
|
||||
|
||||
function Trash() {
|
||||
const { t } = useTranslation();
|
||||
const { documents } = useStores();
|
||||
const user = useCurrentUser();
|
||||
|
||||
return (
|
||||
<Scene icon={<TrashIcon color="currentColor" />} title={t("Trash")}>
|
||||
<Scene
|
||||
icon={<TrashIcon color="currentColor" />}
|
||||
title={t("Trash")}
|
||||
actions={
|
||||
user.isAdmin && (
|
||||
<Action>
|
||||
<EmptyTrashMenu />
|
||||
</Action>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Heading>{t("Trash")}</Heading>
|
||||
<PaginatedDocumentList
|
||||
documents={documents.deleted}
|
||||
|
||||
+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>
|
||||
|
||||
+15
-11
@@ -199,11 +199,17 @@ 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;
|
||||
this.policies = [];
|
||||
this.token = null;
|
||||
});
|
||||
};
|
||||
@@ -236,6 +242,7 @@ export default class AuthStore {
|
||||
collaborativeEditing?: boolean;
|
||||
defaultCollectionId?: string | null;
|
||||
subdomain?: string | null | undefined;
|
||||
allowedDomains?: string[] | null | undefined;
|
||||
}) => {
|
||||
this.isSaving = true;
|
||||
|
||||
@@ -259,14 +266,6 @@ export default class AuthStore {
|
||||
|
||||
client.post(`/auth.delete`);
|
||||
|
||||
// remove user and team from localStorage
|
||||
Storage.set(AUTH_STORE, {
|
||||
user: null,
|
||||
team: null,
|
||||
policies: [],
|
||||
});
|
||||
this.token = null;
|
||||
|
||||
// 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) {
|
||||
@@ -290,7 +289,12 @@ export default class AuthStore {
|
||||
setCookie("sessions", JSON.stringify(sessions), {
|
||||
domain: getCookieDomain(window.location.hostname),
|
||||
});
|
||||
this.team = null;
|
||||
}
|
||||
|
||||
// clear all credentials from cache (and local storage via autorun)
|
||||
this.user = null;
|
||||
this.team = null;
|
||||
this.policies = [];
|
||||
this.token = null;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import BaseModel from "~/models/BaseModel";
|
||||
import Policy from "~/models/Policy";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
|
||||
type PartialWithId<T> = Partial<T> & { id: string };
|
||||
|
||||
@@ -209,7 +210,7 @@ export default abstract class BaseStore<T extends BaseModel> {
|
||||
this.addPolicies(res.policies);
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 403) {
|
||||
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
|
||||
this.remove(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { computed, action } from "mobx";
|
||||
import Collection from "~/models/Collection";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { AuthorizationError, NotFoundError } from "~/utils/errors";
|
||||
import BaseStore from "./BaseStore";
|
||||
import RootStore from "./RootStore";
|
||||
|
||||
@@ -158,7 +159,7 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
||||
this.addPolicies(res.policies);
|
||||
return this.add(res.data);
|
||||
} catch (err) {
|
||||
if (err.statusCode === 403) {
|
||||
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
|
||||
this.remove(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ import path from "path";
|
||||
import invariant from "invariant";
|
||||
import { find, orderBy, filter, compact, omitBy } from "lodash";
|
||||
import { observable, action, computed, runInAction } from "mobx";
|
||||
import { MAX_TITLE_LENGTH } from "@shared/constants";
|
||||
import { DateFilter } from "@shared/types";
|
||||
import { subtractDate } from "@shared/utils/date";
|
||||
import naturalSort from "@shared/utils/naturalSort";
|
||||
import { DocumentValidation } from "@shared/validations";
|
||||
import BaseStore from "~/stores/BaseStore";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
@@ -532,7 +532,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
id: documentId,
|
||||
collectionId,
|
||||
parentDocumentId,
|
||||
index: index,
|
||||
index,
|
||||
});
|
||||
invariant(res?.data, "Data not available");
|
||||
res.data.documents.forEach(this.add);
|
||||
@@ -553,7 +553,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
template: document.template,
|
||||
title: `${document.title.slice(
|
||||
0,
|
||||
MAX_TITLE_LENGTH - append.length
|
||||
DocumentValidation.maxTitleLength - append.length
|
||||
)}${append}`,
|
||||
text: document.text,
|
||||
});
|
||||
@@ -684,6 +684,33 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
async emptyTrash() {
|
||||
const res = await client.post("/documents.empty_trash");
|
||||
invariant(res?.data, "Data should be available");
|
||||
res.data.forEach(
|
||||
({
|
||||
documentId,
|
||||
collectionId,
|
||||
}: {
|
||||
documentId: string;
|
||||
collectionId: string;
|
||||
}) => {
|
||||
this.remove(documentId);
|
||||
|
||||
const share = this.rootStore.shares.getByDocumentId(documentId);
|
||||
if (share) {
|
||||
this.rootStore.shares.remove(share.id);
|
||||
}
|
||||
|
||||
const collection = this.rootStore.collections.data.get(collectionId);
|
||||
if (collection) {
|
||||
collection.refresh();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
archive = async (document: Document) => {
|
||||
const res = await client.post("/documents.archive", {
|
||||
@@ -718,7 +745,10 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
document.updateFromJson(res.data);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
const collection = this.getCollectionForDocument(document);
|
||||
|
||||
const collection = this.rootStore.collections.data.get(
|
||||
document.collectionId
|
||||
);
|
||||
if (collection) {
|
||||
collection.refresh();
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import ToastsStore from "./ToastsStore";
|
||||
import UiStore from "./UiStore";
|
||||
import UsersStore from "./UsersStore";
|
||||
import ViewsStore from "./ViewsStore";
|
||||
import WebhookSubscriptionsStore from "./WebhookSubscriptionStore";
|
||||
|
||||
export default class RootStore {
|
||||
apiKeys: ApiKeysStore;
|
||||
@@ -48,6 +49,7 @@ export default class RootStore {
|
||||
views: ViewsStore;
|
||||
toasts: ToastsStore;
|
||||
fileOperations: FileOperationsStore;
|
||||
webhookSubscriptions: WebhookSubscriptionsStore;
|
||||
|
||||
constructor() {
|
||||
// PoliciesStore must be initialized before AuthStore
|
||||
@@ -75,6 +77,7 @@ export default class RootStore {
|
||||
this.views = new ViewsStore(this);
|
||||
this.fileOperations = new FileOperationsStore(this);
|
||||
this.toasts = new ToastsStore();
|
||||
this.webhookSubscriptions = new WebhookSubscriptionsStore(this);
|
||||
}
|
||||
|
||||
logout() {
|
||||
@@ -100,5 +103,6 @@ export default class RootStore {
|
||||
// this.ui omitted to keep ui settings between sessions
|
||||
this.users.clear();
|
||||
this.views.clear();
|
||||
this.webhookSubscriptions.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ export default class SharesStore extends BaseStore<Share> {
|
||||
try {
|
||||
const res = await client.post(`/${this.modelName}s.info`, {
|
||||
documentId,
|
||||
apiVersion: 2,
|
||||
});
|
||||
|
||||
if (isUndefined(res)) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { action, autorun, computed, observable } from "mobx";
|
||||
import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import Document from "~/models/Document";
|
||||
import { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
|
||||
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
|
||||
import Storage from "~/utils/Storage";
|
||||
|
||||
const UI_STORE = "UI_STORE";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user