mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dcd4c07ace | |||
| 982ab2b48e | |||
| 74d9409cc3 | |||
| 0a6cfe5a6a | |||
| 4a16124a94 | |||
| 294521f162 | |||
| 00481d2bfc | |||
| eace258a86 | |||
| de4b515e64 | |||
| c35c566fef | |||
| d9dc6aa2d7 | |||
| 192802d360 | |||
| cb9773ad85 | |||
| f9d9a82e47 | |||
| 383bac241e | |||
| ea28dc46eb | |||
| 2794057738 | |||
| b7b1f5e1fd | |||
| 8fdd5bf734 | |||
| 086c3ec2d8 | |||
| f370b0296b | |||
| 9b837763e6 | |||
| 3d9a8be867 | |||
| c8caeebdba | |||
| 2c7d5ac3d8 | |||
| 30190866f8 | |||
| 53a08cf307 | |||
| 1c5864deee | |||
| 865e6d048e | |||
| 5e852170f9 | |||
| 71da57773e | |||
| ec35af4bc5 | |||
| 870d9ed41e | |||
| 24170e8684 | |||
| 7ae892fe06 | |||
| 4f537c7578 | |||
| 4bca081faa | |||
| 87b4c9fdba | |||
| 6fe4b1cba1 | |||
| ef2abf824e | |||
| 4b4b0f7037 | |||
| 80d50e3d88 | |||
| ba264974cf | |||
| 71dd4f267b | |||
| 5758ff3459 | |||
| 6568b16ef9 | |||
| ef0412c449 | |||
| 031a7d396f | |||
| c3f5563e7f | |||
| 4ee3929e9d | |||
| 9ab409a640 | |||
| 9cafe75bf2 | |||
| 630b4eff8a | |||
| bf8ca59442 | |||
| 9dd28def67 | |||
| d785389fde | |||
| 1ccd770bce | |||
| 7719d378b0 | |||
| f26f8d4bb9 | |||
| 89d4aeac67 | |||
| dc94a683e7 | |||
| 04f5b08ba1 | |||
| 5924f4909f | |||
| c00bad38e2 | |||
| 11e1ef455f | |||
| 4af69b2758 | |||
| 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"plugins": [
|
||||
@@ -21,7 +20,6 @@
|
||||
"eslint-plugin-import",
|
||||
"eslint-plugin-node",
|
||||
"eslint-plugin-react",
|
||||
"eslint-plugin-react-hooks",
|
||||
"import"
|
||||
],
|
||||
"rules": {
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-1
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"extends": [
|
||||
"../.eslintrc"
|
||||
"../.eslintrc",
|
||||
"plugin:react-hooks/recommended",
|
||||
],
|
||||
"plugins": [
|
||||
"eslint-plugin-react-hooks",
|
||||
],
|
||||
"env": {
|
||||
"jest": true,
|
||||
|
||||
@@ -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];
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
SearchIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import DocumentTemplatizeDialog from "~/components/DocumentTemplatizeDialog";
|
||||
import { createAction } from "~/actions";
|
||||
import { DocumentSection } from "~/actions/sections";
|
||||
@@ -260,8 +260,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];
|
||||
@@ -299,7 +299,8 @@ export const createTemplate = createAction({
|
||||
return (
|
||||
!!activeCollectionId &&
|
||||
stores.policies.abilities(activeCollectionId).update &&
|
||||
!document?.isTemplate
|
||||
!document?.isTemplate &&
|
||||
!document?.isDeleted
|
||||
);
|
||||
},
|
||||
perform: ({ activeDocumentId, stores, t, event }) => {
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
@@ -11,5 +11,5 @@ export default [
|
||||
...rootUserActions,
|
||||
...rootNavigationActions,
|
||||
...rootSettingsActions,
|
||||
...rootDebugActions,
|
||||
...rootDeveloperActions,
|
||||
];
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
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";
|
||||
@@ -7,10 +8,10 @@ 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, sanitizeHref } 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 sanitizeHref(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 = sanitizeHref(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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,7 +23,7 @@ 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";
|
||||
@@ -219,8 +219,8 @@ function DocumentMenu({
|
||||
);
|
||||
|
||||
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
|
||||
@@ -447,7 +447,7 @@ function DocumentMenu({
|
||||
/>
|
||||
</Style>
|
||||
)}
|
||||
{showDisplayOptions && !isMobile && (
|
||||
{showDisplayOptions && !isMobile && can.update && (
|
||||
<Style>
|
||||
<ToggleMenuItem
|
||||
width={26}
|
||||
|
||||
+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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -44,6 +44,13 @@ 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"
|
||||
),
|
||||
},
|
||||
{
|
||||
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;
|
||||
+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";
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import BaseStore, { RPCAction } from "./BaseStore";
|
||||
import RootStore from "./RootStore";
|
||||
|
||||
export default class WebhookSubscriptionsStore extends BaseStore<
|
||||
WebhookSubscription
|
||||
> {
|
||||
actions = [
|
||||
RPCAction.List,
|
||||
RPCAction.Create,
|
||||
RPCAction.Delete,
|
||||
RPCAction.Update,
|
||||
];
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, WebhookSubscription);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -13,4 +13,4 @@ Enzyme.configure({
|
||||
|
||||
global.localStorage = localStorage;
|
||||
|
||||
require("jest-fetch-mock").enableMocks();
|
||||
require("jest-fetch-mock").enableMocks();
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import { Location } from "history";
|
||||
import { Location, LocationDescriptor } from "history";
|
||||
import { TFunction } from "react-i18next";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
@@ -40,7 +40,7 @@ export type MenuHeading = {
|
||||
export type MenuInternalLink = {
|
||||
type: "route";
|
||||
title: React.ReactNode;
|
||||
to: string;
|
||||
to: LocationDescriptor;
|
||||
visible?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -141,16 +141,11 @@ class ApiClient {
|
||||
|
||||
// Handle failed responses
|
||||
const error: {
|
||||
statusCode?: number;
|
||||
response?: Response;
|
||||
message?: string;
|
||||
error?: string;
|
||||
data?: Record<string, any>;
|
||||
} = {};
|
||||
|
||||
error.statusCode = response.status;
|
||||
error.response = response;
|
||||
|
||||
try {
|
||||
const parsed = await response.json();
|
||||
error.message = parsed.message || "";
|
||||
@@ -186,7 +181,7 @@ class ApiClient {
|
||||
throw new ServiceUnavailableError(error.message);
|
||||
}
|
||||
|
||||
throw new RequestError(`Error ${error.statusCode}: ${error.message}`);
|
||||
throw new RequestError(`Error ${response.status}: ${error.message}`);
|
||||
};
|
||||
|
||||
get = (
|
||||
|
||||
@@ -8,7 +8,10 @@ export async function loadPolyfills() {
|
||||
|
||||
if (!supportsResizeObserver()) {
|
||||
polyfills.push(
|
||||
import("@juggle/resize-observer").then((module) => {
|
||||
import(
|
||||
/* webpackChunkName: "resize-observer" */
|
||||
"@juggle/resize-observer"
|
||||
).then((module) => {
|
||||
window.ResizeObserver = module.ResizeObserver;
|
||||
})
|
||||
);
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
The Outline team takes security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security issue, email [hello@getoutline.com](mailto:hello@getoutline.com) and include the word "SECURITY" in the subject line.
|
||||
If you discover a security vulnerability in outline, please disclose it via [our huntr page](https://huntr.dev/repos/outline/outline/). Bounty eligibility, CVE assignment, response times and past reports are all there.
|
||||
|
||||
The Outline team will send a response indicating the next steps in handling your report. After the initial reply to your report you will be kept informed of the progress towards a fix and full announcement.
|
||||
|
||||
|
||||
+47
-41
@@ -16,11 +16,13 @@
|
||||
"lint": "eslint app server shared",
|
||||
"deploy": "git push heroku master",
|
||||
"prepare": "husky install",
|
||||
"postinstall": "rimraf node_modules/prosemirror-view/dist/index.d.ts",
|
||||
"heroku-postbuild": "yarn build:webpack && yarn build:server && yarn copy:i18n && yarn db:migrate",
|
||||
"sequelize:migrate": "sequelize db:migrate",
|
||||
"db:create-migration": "sequelize migration:create",
|
||||
"db:migrate": "sequelize db:migrate",
|
||||
"db:rollback": "sequelize db:migrate:undo",
|
||||
"db:reset": "sequelize db:drop && sequelize db:create && sequelize db:migrate",
|
||||
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
|
||||
"test": "yarn test:app && yarn test:server",
|
||||
"test:app": "jest --config=app/.jestconfig.json --runInBand --forceExit",
|
||||
@@ -42,14 +44,14 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.16.0",
|
||||
"@babel/core": "^7.18.6",
|
||||
"@babel/plugin-proposal-decorators": "^7.10.5",
|
||||
"@babel/plugin-transform-destructuring": "^7.10.4",
|
||||
"@babel/plugin-transform-regenerator": "^7.10.4",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@babel/preset-react": "^7.16.0",
|
||||
"@bull-board/api": "^3.11.1",
|
||||
"@bull-board/koa": "^3.11.1",
|
||||
"@bull-board/api": "^4.0.0",
|
||||
"@bull-board/koa": "^4.0.0",
|
||||
"@dnd-kit/core": "^4.0.3",
|
||||
"@dnd-kit/modifiers": "^4.0.0",
|
||||
"@dnd-kit/sortable": "^5.1.0",
|
||||
@@ -58,7 +60,7 @@
|
||||
"@hocuspocus/server": "^1.0.0-alpha.102",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.44",
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"@outlinewiki/koa-passport": "^4.1.4",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@renderlesskit/react": "^0.6.0",
|
||||
"@sentry/node": "^6.3.1",
|
||||
@@ -67,6 +69,7 @@
|
||||
"@theo.gravity/datadog-apm": "2.1.0",
|
||||
"@tippy.js/react": "^2.2.2",
|
||||
"@tommoor/remove-markdown": "^0.3.2",
|
||||
"@types/mermaid": "^8.2.9",
|
||||
"autotrack": "^2.4.1",
|
||||
"aws-sdk": "^2.1044.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
@@ -97,7 +100,7 @@
|
||||
"fs-extra": "^4.0.2",
|
||||
"fuzzy-search": "^3.2.1",
|
||||
"gemoji": "6.x",
|
||||
"http-errors": "1.4.0",
|
||||
"http-errors": "2.0.0",
|
||||
"i18next": "^20.6.1",
|
||||
"i18next-http-backend": "^1.3.2",
|
||||
"immutable": "^4.0.0",
|
||||
@@ -106,18 +109,17 @@
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
"json-loader": "0.5.4",
|
||||
"jsonwebtoken": "^8.5.0",
|
||||
"jszip": "^3.7.1",
|
||||
"jszip": "^3.10.0",
|
||||
"kbar": "0.1.0-beta.28",
|
||||
"koa": "^2.10.0",
|
||||
"koa": "^2.13.4",
|
||||
"koa-body": "^4.2.0",
|
||||
"koa-compress": "2.0.0",
|
||||
"koa-convert": "1.2.0",
|
||||
"koa-helmet": "5.2.0",
|
||||
"koa-jwt": "^3.6.0",
|
||||
"koa-compress": "^5.1.0",
|
||||
"koa-convert": "^2.0.0",
|
||||
"koa-helmet": "^6.1.0",
|
||||
"koa-logger": "^3.2.1",
|
||||
"koa-mount": "^3.0.0",
|
||||
"koa-onerror": "^4.1.0",
|
||||
"koa-router": "7.0.1",
|
||||
"koa-onerror": "^4.2.0",
|
||||
"koa-router": "7.4.0",
|
||||
"koa-send": "5.0.1",
|
||||
"koa-sslify": "2.1.2",
|
||||
"koa-static": "^4.0.1",
|
||||
@@ -126,23 +128,25 @@
|
||||
"markdown-it": "^12.3.2",
|
||||
"markdown-it-container": "^3.0.0",
|
||||
"markdown-it-emoji": "^2.0.0",
|
||||
"mermaid": "9.1.3",
|
||||
"mime-types": "^2.1.35",
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-fetch": "2.6.7",
|
||||
"nodemailer": "^6.6.1",
|
||||
"outline-icons": "^1.42.0",
|
||||
"outline-icons": "^1.43.1",
|
||||
"oy-vey": "^0.10.0",
|
||||
"passport": "^0.4.1",
|
||||
"passport": "^0.6.0",
|
||||
"passport-google-oauth2": "^0.2.0",
|
||||
"passport-oauth2": "^1.6.1",
|
||||
"passport-slack-oauth2": "^1.1.0",
|
||||
"passport-slack-oauth2": "^1.1.1",
|
||||
"pg": "^8.5.1",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"polished": "^3.7.2",
|
||||
"prosemirror-commands": "^1.2.1",
|
||||
"prosemirror-dropcursor": "^1.4.0",
|
||||
"prosemirror-gapcursor": "^1.2.1",
|
||||
"prosemirror-gapcursor": "^1.3.1",
|
||||
"prosemirror-history": "^1.2.0",
|
||||
"prosemirror-inputrules": "^1.1.3",
|
||||
"prosemirror-keymap": "^1.1.5",
|
||||
@@ -153,7 +157,7 @@
|
||||
"prosemirror-tables": "^1.1.1",
|
||||
"prosemirror-transform": "1.2.5",
|
||||
"prosemirror-utils": "^0.9.6",
|
||||
"prosemirror-view": "1.23.6",
|
||||
"prosemirror-view": "1.26.5",
|
||||
"query-string": "^7.0.1",
|
||||
"quoted-printable": "^1.0.1",
|
||||
"randomstring": "1.1.5",
|
||||
@@ -162,10 +166,11 @@
|
||||
"react-avatar-editor": "^11.1.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-dnd": "^14.0.1",
|
||||
"react-dnd-html5-backend": "^14.0.0",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-dropzone": "^11.3.2",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hook-form": "^7.31.2",
|
||||
"react-i18next": "^11.16.6",
|
||||
"react-medium-image-zoom": "^3.1.3",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
@@ -174,24 +179,25 @@
|
||||
"react-table": "^7.7.0",
|
||||
"react-virtualized-auto-sizer": "^1.0.5",
|
||||
"react-waypoint": "^10.1.0",
|
||||
"react-window": "^1.8.6",
|
||||
"react-window": "^1.8.7",
|
||||
"reakit": "^1.3.10",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"refractor": "^3.5.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"semver": "^7.3.2",
|
||||
"request-filtering-agent": "^1.1.2",
|
||||
"semver": "^7.3.7",
|
||||
"sequelize": "^6.20.1",
|
||||
"sequelize-cli": "^6.4.1",
|
||||
"sequelize-encrypted": "^1.0.0",
|
||||
"sequelize-typescript": "^2.1.3",
|
||||
"slate": "0.45.0",
|
||||
"slate-md-serializer": "5.5.4",
|
||||
"slug": "^4.0.4",
|
||||
"slug": "^5.3.0",
|
||||
"slugify": "^1.6.5",
|
||||
"smooth-scroll-into-view-if-needed": "^1.1.32",
|
||||
"socket.io": "^2.4.0",
|
||||
"socket.io-redis": "^5.4.0",
|
||||
"socketio-auth": "^0.1.1",
|
||||
"socket.io": "^3.1.2",
|
||||
"socket.io-client": "^3.1.3",
|
||||
"socket.io-redis": "^6.1.0",
|
||||
"stoppable": "^1.1.0",
|
||||
"string-replace-to-array": "^1.0.3",
|
||||
"styled-components": "^5.2.3",
|
||||
@@ -208,7 +214,7 @@
|
||||
"winston": "^3.3.3",
|
||||
"ws": "^7.5.3",
|
||||
"y-indexeddb": "^9.0.6",
|
||||
"yjs": "^13.5.34"
|
||||
"yjs": "^13.5.39"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.10.5",
|
||||
@@ -222,28 +228,29 @@
|
||||
"@types/emoji-regex": "^9.2.0",
|
||||
"@types/enzyme": "^3.10.10",
|
||||
"@types/enzyme-adapter-react-16": "^1.0.6",
|
||||
"@types/formidable": "^2.0.0",
|
||||
"@types/formidable": "^2.0.5",
|
||||
"@types/fs-extra": "^9.0.13",
|
||||
"@types/fuzzy-search": "^2.1.2",
|
||||
"@types/google.analytics": "^0.0.42",
|
||||
"@types/invariant": "^2.2.35",
|
||||
"@types/ioredis": "^4.28.1",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/jsonwebtoken": "^8.5.5",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/koa": "^2.13.4",
|
||||
"@types/koa-compress": "^4.0.3",
|
||||
"@types/koa-helmet": "^6.0.4",
|
||||
"@types/koa-logger": "^3.1.2",
|
||||
"@types/koa-mount": "^4.0.1",
|
||||
"@types/koa-router": "^7.4.4",
|
||||
"@types/koa-sslify": "^4.0.2",
|
||||
"@types/koa-sslify": "^2.1.0",
|
||||
"@types/koa-static": "^4.0.2",
|
||||
"@types/markdown-it": "^12.2.3",
|
||||
"@types/markdown-it-container": "^2.0.4",
|
||||
"@types/markdown-it-emoji": "^2.0.2",
|
||||
"@types/mime-types": "^2.1.1",
|
||||
"@types/natural-sort": "^0.0.21",
|
||||
"@types/node": "15.12.2",
|
||||
"@types/node": "18.0.6",
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/passport-oauth2": "^1.4.11",
|
||||
"@types/prosemirror-commands": "^1.0.1",
|
||||
@@ -271,11 +278,9 @@
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/refractor": "^3.0.2",
|
||||
"@types/semver": "^7.3.9",
|
||||
"@types/semver": "^7.3.10",
|
||||
"@types/sequelize": "^4.28.10",
|
||||
"@types/slug": "^5.0.2",
|
||||
"@types/socket.io": "2.1.13",
|
||||
"@types/socket.io-parser": "^2.2.1",
|
||||
"@types/slug": "^5.0.3",
|
||||
"@types/stoppable": "^1.1.1",
|
||||
"@types/styled-components": "^5.1.15",
|
||||
"@types/throng": "^5.0.3",
|
||||
@@ -291,11 +296,11 @@
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
|
||||
"babel-plugin-transform-typescript-metadata": "^0.3.2",
|
||||
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.3",
|
||||
"concurrently": "^6.2.1",
|
||||
"concurrently": "^7.3.0",
|
||||
"enzyme": "^3.11.0",
|
||||
"enzyme-adapter-react-16": "^1.15.6",
|
||||
"eslint": "^7.6.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-import-resolver-typescript": "^2.5.0",
|
||||
"eslint-plugin-es": "^4.1.0",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
@@ -313,12 +318,12 @@
|
||||
"koa-webpack-dev-middleware": "^1.4.5",
|
||||
"koa-webpack-hot-middleware": "^1.0.3",
|
||||
"lint-staged": "^12.3.8",
|
||||
"nodemon": "^2.0.15",
|
||||
"nodemon": "^2.0.18",
|
||||
"prettier": "^2.0.5",
|
||||
"react-refresh": "^0.9.0",
|
||||
"rimraf": "^2.5.4",
|
||||
"terser-webpack-plugin": "^4.1.0",
|
||||
"typescript": "^4.4.4",
|
||||
"typescript": "^4.7.4",
|
||||
"url-loader": "^0.6.2",
|
||||
"webpack": "4.44.1",
|
||||
"webpack-cli": "^3.3.12",
|
||||
@@ -328,11 +333,12 @@
|
||||
"yarn-deduplicate": "^3.1.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"d3": "^7.0.0",
|
||||
"node-fetch": "^2.6.7",
|
||||
"socket.io-parser": "^3.4.0",
|
||||
"prosemirror-transform": "1.2.5",
|
||||
"dot-prop": "^5.2.0",
|
||||
"js-yaml": "^3.14.1"
|
||||
"js-yaml": "^3.14.1",
|
||||
"jpeg-js": "0.4.4"
|
||||
},
|
||||
"version": "0.64.3"
|
||||
"version": "0.65.2"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 533 B |
+15
-1
@@ -24,5 +24,19 @@
|
||||
}
|
||||
],
|
||||
"tsconfig-paths-module-resolver"
|
||||
]
|
||||
],
|
||||
"env": {
|
||||
"production": {
|
||||
"ignore": [
|
||||
"**/__mocks__",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"ignore": [
|
||||
"**/__mocks__",
|
||||
"**/*.test.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
|
||||
fetchMock.enableMocks();
|
||||
|
||||
// changes default behavior of fetchMock to use the real 'fetch' implementation.
|
||||
// Mocks can now be enabled in each individual test with fetchMock.doMock()
|
||||
fetchMock.dontMock();
|
||||
|
||||
export default fetch;
|
||||
@@ -30,7 +30,11 @@ export default class MetricsExtension implements Extension {
|
||||
});
|
||||
Metrics.gaugePerInstance(
|
||||
"collaboration.connections_count",
|
||||
instance.getConnectionsCount()
|
||||
instance.getConnectionsCount() + 1
|
||||
);
|
||||
Metrics.gaugePerInstance(
|
||||
"collaboration.documents_count",
|
||||
instance.getDocumentsCount()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,8 +47,8 @@ export default class MetricsExtension implements Extension {
|
||||
instance.getConnectionsCount()
|
||||
);
|
||||
Metrics.gaugePerInstance(
|
||||
"collaboration.documents_count", // -1 adjustment because hook is called before document is removed
|
||||
instance.getDocumentsCount() - 1
|
||||
"collaboration.documents_count",
|
||||
instance.getDocumentsCount()
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
onLoadDocumentPayload,
|
||||
Extension,
|
||||
} from "@hocuspocus/server";
|
||||
import invariant from "invariant";
|
||||
import * as Y from "yjs";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import Logger from "@server/logging/Logger";
|
||||
@@ -30,11 +29,11 @@ export default class PersistenceExtension implements Extension {
|
||||
const document = await Document.scope("withState").findOne({
|
||||
transaction,
|
||||
lock: transaction.LOCK.UPDATE,
|
||||
rejectOnEmpty: true,
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
});
|
||||
invariant(document, "Document not found");
|
||||
|
||||
if (document.state) {
|
||||
const ydoc = new Y.Doc();
|
||||
|
||||
@@ -108,6 +108,44 @@ describe("accountProvisioner", () => {
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should allow authentication by email matching", async () => {
|
||||
const existingTeam = await buildTeam();
|
||||
const providers = await existingTeam.$get("authenticationProviders");
|
||||
const authenticationProvider = providers[0];
|
||||
const userWithoutAuth = await buildUser({
|
||||
email: "email@example.com",
|
||||
teamId: existingTeam.id,
|
||||
authentications: [],
|
||||
});
|
||||
|
||||
const { user, isNewUser, isNewTeam } = await accountProvisioner({
|
||||
ip,
|
||||
user: {
|
||||
name: userWithoutAuth.name,
|
||||
email: "email@example.com",
|
||||
avatarUrl: userWithoutAuth.avatarUrl,
|
||||
},
|
||||
team: {
|
||||
name: existingTeam.name,
|
||||
avatarUrl: existingTeam.avatarUrl,
|
||||
subdomain: "example",
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: authenticationProvider.name,
|
||||
providerId: authenticationProvider.providerId,
|
||||
},
|
||||
authentication: {
|
||||
providerId: "anything",
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
expect(user.id).toEqual(userWithoutAuth.id);
|
||||
expect(isNewTeam).toEqual(false);
|
||||
expect(isNewUser).toEqual(false);
|
||||
expect(user.authentications.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should throw an error when authentication provider is disabled", async () => {
|
||||
const existingTeam = await buildTeam();
|
||||
const providers = await existingTeam.$get("authenticationProviders");
|
||||
|
||||
@@ -3,37 +3,63 @@ import { UniqueConstraintError } from "sequelize";
|
||||
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
|
||||
import {
|
||||
AuthenticationError,
|
||||
InvalidAuthenticationError,
|
||||
EmailAuthenticationRequiredError,
|
||||
AuthenticationProviderDisabledError,
|
||||
} from "@server/errors";
|
||||
import { APM } from "@server/logging/tracing";
|
||||
import { Collection, Team, User } from "@server/models";
|
||||
import teamCreator from "./teamCreator";
|
||||
import userCreator from "./userCreator";
|
||||
import { AuthenticationProvider, Collection, Team, User } from "@server/models";
|
||||
import teamProvisioner from "./teamProvisioner";
|
||||
import userProvisioner from "./userProvisioner";
|
||||
|
||||
type Props = {
|
||||
/** The IP address of the incoming request */
|
||||
ip: string;
|
||||
/** Details of the user logging in from SSO provider */
|
||||
user: {
|
||||
/** The displayed name of the user */
|
||||
name: string;
|
||||
/** The email address of the user */
|
||||
email: string;
|
||||
/** The public url of an image representing the user */
|
||||
avatarUrl?: string | null;
|
||||
/** The username of the user */
|
||||
username?: string;
|
||||
};
|
||||
/** Details of the team the user is logging into */
|
||||
team: {
|
||||
/**
|
||||
* The internal ID of the team that is being logged into based on the
|
||||
* subdomain that the request came from, if any.
|
||||
*/
|
||||
teamId?: string;
|
||||
/** The displayed name of the team */
|
||||
name: string;
|
||||
/** The domain name from the email of the user logging in */
|
||||
domain?: string;
|
||||
/** The preferred subdomain to provision for the team if not yet created */
|
||||
subdomain: string;
|
||||
/** The public url of an image representing the team */
|
||||
avatarUrl?: string | null;
|
||||
};
|
||||
/** Details of the authentication provider being used */
|
||||
authenticationProvider: {
|
||||
/** The name of the authentication provider, eg "google" */
|
||||
name: string;
|
||||
/** External identifier of the authentication provider */
|
||||
providerId: string;
|
||||
};
|
||||
/** Details of the authentication from SSO provider */
|
||||
authentication: {
|
||||
/** External identifier of the user in the authentication provider */
|
||||
providerId: string;
|
||||
/** The scopes granted by the access token */
|
||||
scopes: string[];
|
||||
/** The token provided by the authentication provider */
|
||||
accessToken?: string;
|
||||
/** The refresh token provided by the authentication provider */
|
||||
refreshToken?: string;
|
||||
/** A number of seconds that the given access token expires in */
|
||||
expiresIn?: number;
|
||||
};
|
||||
};
|
||||
@@ -53,17 +79,45 @@ async function accountProvisioner({
|
||||
authentication: authenticationParams,
|
||||
}: Props): Promise<AccountProvisionerResult> {
|
||||
let result;
|
||||
let emailMatchOnly;
|
||||
|
||||
try {
|
||||
result = await teamCreator({
|
||||
name: teamParams.name,
|
||||
domain: teamParams.domain,
|
||||
subdomain: teamParams.subdomain,
|
||||
avatarUrl: teamParams.avatarUrl,
|
||||
result = await teamProvisioner({
|
||||
...teamParams,
|
||||
authenticationProvider: authenticationProviderParams,
|
||||
ip,
|
||||
});
|
||||
} catch (err) {
|
||||
throw AuthenticationError(err.message);
|
||||
// The account could not be provisioned for the provided teamId
|
||||
// check to see if we can try authentication using email matching only
|
||||
if (err.id === "invalid_authentication") {
|
||||
const authenticationProvider = await AuthenticationProvider.findOne({
|
||||
where: {
|
||||
name: authenticationProviderParams.name, // example: "google"
|
||||
teamId: teamParams.teamId,
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Team,
|
||||
as: "team",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (authenticationProvider) {
|
||||
emailMatchOnly = true;
|
||||
result = {
|
||||
authenticationProvider,
|
||||
team: authenticationProvider.team,
|
||||
isNewTeam: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
throw InvalidAuthenticationError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
invariant(result, "Team creator result must exist");
|
||||
@@ -74,20 +128,21 @@ async function accountProvisioner({
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await userCreator({
|
||||
const result = await userProvisioner({
|
||||
name: userParams.name,
|
||||
email: userParams.email,
|
||||
username: userParams.username,
|
||||
isAdmin: isNewTeam || undefined,
|
||||
avatarUrl: userParams.avatarUrl,
|
||||
teamId: team.id,
|
||||
emailMatchOnly,
|
||||
ip,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
...authenticationParams,
|
||||
expiresAt: authenticationParams.expiresIn
|
||||
? new Date(Date.now() + authenticationParams.expiresIn * 1000)
|
||||
: undefined,
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
},
|
||||
});
|
||||
const { isNewUser, user } = result;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { yDocToProsemirrorJSON } from "@getoutline/y-prosemirror";
|
||||
import invariant from "invariant";
|
||||
import { uniq } from "lodash";
|
||||
import { Node } from "prosemirror-model";
|
||||
import * as Y from "yjs";
|
||||
@@ -25,9 +24,9 @@ export default async function documentCollaborativeUpdater({
|
||||
of: Document,
|
||||
level: transaction.LOCK.UPDATE,
|
||||
},
|
||||
rejectOnEmpty: true,
|
||||
paranoid: false,
|
||||
});
|
||||
invariant(document, "document not found");
|
||||
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const node = Node.fromJSON(schema, yDocToProsemirrorJSON(ydoc, "default"));
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import invariant from "invariant";
|
||||
import { Transaction } from "sequelize";
|
||||
import { Document, Event, User } from "@server/models";
|
||||
|
||||
@@ -105,14 +104,12 @@ export default async function documentCreator({
|
||||
// reload to get all of the data needed to present (user, collection etc)
|
||||
// we need to specify publishedAt to bypass default scope that only returns
|
||||
// published documents
|
||||
const doc = await Document.findOne({
|
||||
return await Document.findOne({
|
||||
where: {
|
||||
id: document.id,
|
||||
publishedAt: document.publishedAt,
|
||||
},
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
invariant(doc, "Document must exist");
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -148,6 +148,21 @@ describe("documentImporter", () => {
|
||||
expect(response.title).toEqual("Heading 1");
|
||||
});
|
||||
|
||||
it("should handle only title", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "markdown.md";
|
||||
const content = `# Title`;
|
||||
const response = await documentImporter({
|
||||
user,
|
||||
mimeType: "text/plain",
|
||||
fileName,
|
||||
content,
|
||||
ip,
|
||||
});
|
||||
expect(response.text).toEqual("");
|
||||
expect(response.title).toEqual("Title");
|
||||
});
|
||||
|
||||
it("should fallback to extension if mimetype unknown", async () => {
|
||||
const user = await buildUser();
|
||||
const fileName = "markdown.md";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user