Compare commits

..

4 Commits

Author SHA1 Message Date
Tom Moor bff6a80b67 styles 2022-06-25 19:07:18 +02:00
Tom Moor 07ad87f65f fix: Don't send email notification with empty diff 2022-06-24 09:57:53 +02:00
Tom Moor dd471328db fix: Collection name missing in notification email
fix: Email styles
2022-06-24 09:50:39 +02:00
Tom Moor 04f0983e20 Bringing across still relevant work from email-diff branch 2022-06-23 10:54:11 +02:00
357 changed files with 4503 additions and 9998 deletions
-8
View File
@@ -35,14 +35,6 @@
"displayName": false
}
]
],
"ignore": [
"**/*.test.ts"
]
},
"development": {
"ignore": [
"**/*.test.ts"
]
}
}
+2
View File
@@ -12,6 +12,7 @@
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended"
],
"plugins": [
@@ -20,6 +21,7 @@
"eslint-plugin-import",
"eslint-plugin-node",
"eslint-plugin-react",
"eslint-plugin-react-hooks",
"import"
],
"rules": {
-11
View File
@@ -1,11 +0,0 @@
# 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"
-1
View File
@@ -3,7 +3,6 @@ build
node_modules/*
.env
.log
.vscode/*
npm-debug.log
stats.json
.DS_Store
+1 -5
View File
@@ -1,10 +1,6 @@
{
"extends": [
"../.eslintrc",
"plugin:react-hooks/recommended",
],
"plugins": [
"eslint-plugin-react-hooks",
"../.eslintrc"
],
"env": {
"jest": true,
+32
View File
@@ -0,0 +1,32 @@
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];
-50
View File
@@ -1,50 +0,0 @@
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];
+4 -5
View File
@@ -13,7 +13,7 @@ import {
SearchIcon,
} from "outline-icons";
import * as React from "react";
import { getEventFiles } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
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) => {
const files = getEventFiles(ev);
input.onchange = async (ev: Event) => {
const files = getDataTransferFiles(ev);
try {
const file = files[0];
@@ -299,8 +299,7 @@ export const createTemplate = createAction({
return (
!!activeCollectionId &&
stores.policies.abilities(activeCollectionId).update &&
!document?.isTemplate &&
!document?.isDeleted
!document?.isTemplate
);
},
perform: ({ activeDocumentId, stores, t, event }) => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { rootCollectionActions } from "./definitions/collections";
import { rootDeveloperActions } from "./definitions/developer";
import { rootDebugActions } from "./definitions/debug";
import { rootDocumentActions } from "./definitions/documents";
import { rootNavigationActions } from "./definitions/navigation";
import { rootSettingsActions } from "./definitions/settings";
@@ -11,5 +11,5 @@ export default [
...rootUserActions,
...rootNavigationActions,
...rootSettingsActions,
...rootDeveloperActions,
...rootDebugActions,
];
+1 -1
View File
@@ -2,7 +2,7 @@ import { ActionContext } from "~/types";
export const CollectionSection = ({ t }: ActionContext) => t("Collection");
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DebugSection = ({ t }: ActionContext) => t("Debug");
export const DocumentSection = ({ t }: ActionContext) => t("Document");
+1 -1
View File
@@ -37,7 +37,7 @@ function Breadcrumb({
return (
<Flex justify="flex-start" align="center">
{topLevelItems.map((item, index) => (
<React.Fragment key={String(item.to) || index}>
<React.Fragment key={item.to || index}>
{item.icon}
{item.to ? (
<Item
+1 -2
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { ExpandedIcon } from "outline-icons";
import { darken, lighten } from "polished";
import * as React from "react";
@@ -156,7 +155,7 @@ export type Props<T> = {
primary?: boolean;
fullwidth?: boolean;
as?: T;
to?: LocationDescriptor;
to?: string;
borderOnHover?: boolean;
href?: string;
"data-on"?: string;
+12 -1
View File
@@ -1,6 +1,17 @@
import * as React from "react";
import styled from "styled-components";
const ButtonLink = styled.button`
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`
margin: 0;
padding: 0;
border: 0;
+1 -2
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { CheckmarkIcon } from "outline-icons";
import * as React from "react";
import { MenuItem as BaseMenuItem } from "reakit/Menu";
@@ -11,7 +10,7 @@ type Props = {
selected?: boolean;
disabled?: boolean;
dangerous?: boolean;
to?: LocationDescriptor;
to?: string;
href?: string;
target?: "_blank";
as?: string | React.ComponentType<any>;
+1 -2
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -36,7 +35,7 @@ type Props = {
showLastViewed?: boolean;
showParentDocuments?: boolean;
document: Document;
to?: LocationDescriptor;
to?: string;
};
const DocumentMeta: React.FC<Props> = ({
+1 -2
View File
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import { observer, useObserver } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
@@ -13,7 +12,7 @@ import useStores from "~/hooks/useStores";
type Props = {
document: Document;
isDraft: boolean;
to?: LocationDescriptor;
to?: string;
rtl?: boolean;
};
+8 -29
View File
@@ -1,6 +1,5 @@
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";
@@ -8,10 +7,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 { getDataTransferFiles } from "@shared/utils/files";
import { supportedImageMimeTypes } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
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";
@@ -178,41 +177,21 @@ 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;
}
// Find a valid position at the end of the document to insert our content
// 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
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,
-1
View File
@@ -2,7 +2,6 @@ import styled from "styled-components";
const Empty = styled.p`
color: ${(props) => props.theme.textTertiary};
user-select: none;
`;
export default Empty;
+13 -3
View File
@@ -40,7 +40,6 @@ 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";
@@ -201,7 +200,18 @@ 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;
@@ -262,7 +272,7 @@ function IconPicker({ onOpen, onClose, icon, color, onChange }: Props) {
<ColorPicker
color={color}
onChange={(color) => onChange(color.hex, icon)}
colors={colorPalette}
colors={colors}
triangle="hide"
styles={{
default: {
+30 -29
View File
@@ -104,17 +104,31 @@ export const LabelText = styled.div`
display: inline-block;
`;
export type Props = React.InputHTMLAttributes<
HTMLInputElement | HTMLTextAreaElement
> & {
export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
type?: "text" | "email" | "checkbox" | "search" | "textarea";
labelHidden?: boolean;
value?: string;
label?: string;
className?: string;
labelHidden?: boolean;
flex?: boolean;
short?: boolean;
margin?: string | number;
icon?: React.ReactNode;
innerRef?: React.Ref<any>;
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;
onFocus?: (ev: React.SyntheticEvent) => unknown;
onBlur?: (ev: React.SyntheticEvent) => unknown;
};
@@ -157,6 +171,8 @@ class Input extends React.Component<Props> {
...rest
} = this.props;
const InputComponent: React.ComponentType =
type === "textarea" ? RealTextarea : RealInput;
const wrappedLabel = <LabelText>{label}</LabelText>;
return (
@@ -170,24 +186,15 @@ class Input extends React.Component<Props> {
))}
<Outline focused={this.focused} margin={margin}>
{icon && <IconWrapper>{icon}</IconWrapper>}
{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}
/>
)}
<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}
/>
</Outline>
</label>
</Wrapper>
@@ -195,10 +202,4 @@ 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;
-53
View File
@@ -1,53 +0,0 @@
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("Youre 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;
}
}
`;
+1 -5
View File
@@ -69,11 +69,7 @@ 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
View File
@@ -2,7 +2,6 @@ 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 = {
@@ -41,7 +40,6 @@ 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}
+16 -34
View File
@@ -34,19 +34,12 @@ 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;
@@ -87,7 +80,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
this.isFetchingMore = false;
};
@action
fetchResults = async () => {
if (!this.props.fetch) {
return;
@@ -95,30 +87,25 @@ 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;
try {
const results = await this.props.fetch({
limit,
offset: this.offset,
...this.props.options,
});
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;
} 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;
}
this.renderCount += limit;
// only the most recent fetch should end the loading state
if (counter >= this.fetchCounter) {
this.isFetching = false;
this.isFetchingMore = false;
}
};
@@ -151,7 +138,6 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
auth,
empty = null,
renderHeading,
renderError,
onEscape,
} = this.props;
@@ -171,10 +157,6 @@ 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;
}
+3 -7
View File
@@ -10,7 +10,6 @@ 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";
@@ -35,15 +34,12 @@ function AppSidebar() {
const { t } = useTranslation();
const { documents } = useStores();
const team = useCurrentTeam();
const user = useCurrentUser();
const can = usePolicy(team.id);
React.useEffect(() => {
if (!user.isViewer) {
documents.fetchDrafts();
documents.fetchTemplates();
}
}, [documents, user.isViewer]);
documents.fetchDrafts();
documents.fetchTemplates();
}, [documents]);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
@@ -3,13 +3,12 @@ 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";
@@ -19,10 +18,39 @@ 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,
@@ -43,46 +71,45 @@ 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>
<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>
<Relative>{isPreloaded ? content : <Fade>{content}</Fade>}</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={DocumentValidation.maxTitleLength}
maxLength={MAX_TITLE_LENGTH}
/>
}
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, LocationDescriptor } from "history";
import { Location, createLocation } 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: LocationDescriptor | ((location: Location) => LocationDescriptor),
to: string | Record<string, any>,
currentLocation: Location
) => (typeof to === "function" ? to(currentLocation) : to);
const normalizeToLocation = (
to: LocationDescriptor,
to: string | Record<string, any>,
currentLocation: Location
) => {
return typeof to === "string"
@@ -30,15 +30,17 @@ const joinClassnames = (...classnames: (string | undefined)[]) => {
return classnames.filter((i) => i).join(" ");
};
export type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
export type Props = React.HTMLAttributes<HTMLAnchorElement> & {
activeClassName?: string;
activeStyle?: React.CSSProperties;
className?: string;
scrollIntoViewIfNeeded?: boolean;
exact?: boolean;
isActive?: (match: match | null, location: Location) => boolean;
location?: Location;
strict?: boolean;
to: LocationDescriptor;
style?: React.CSSProperties;
to: string | Record<string, any>;
};
/**
@@ -1,4 +1,3 @@
import { LocationDescriptor } from "history";
import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
@@ -15,7 +14,8 @@ export type DragObject = NavigationNode & {
};
type Props = Omit<NavLinkProps, "to"> & {
to?: LocationDescriptor;
to?: string | Record<string, any>;
href?: string | Record<string, any>;
innerRef?: (ref: HTMLElement | null | undefined) => void;
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
onMouseEnter?: React.MouseEventHandler<HTMLAnchorElement>;
@@ -32,7 +32,6 @@ export default function Version() {
return (
<SidebarLink
target="_blank"
href="https://github.com/outline/outline/releases"
label={
<>
+13 -17
View File
@@ -3,17 +3,22 @@ import { find } from "lodash";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { io, Socket } from "socket.io-client";
import io from "socket.io-client";
import RootStore from "~/stores/RootStore";
import withStores from "~/components/withStores";
import { AuthorizationError, NotFoundError } from "~/utils/errors";
import { getVisibilityListener, getPageVisible } from "~/utils/pageVisibility";
type SocketWithAuthentication = Socket & {
type SocketWithAuthentication = {
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 = React.createContext<SocketWithAuthentication | null>(
export const SocketContext: any = React.createContext<SocketWithAuthentication | null>(
null
);
@@ -93,7 +98,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.io.on("reconnect_attempt", () => {
this.socket.on("reconnect_attempt", () => {
if (this.socket) {
this.socket.io.opts.transports = auth?.team?.domain
? ["websocket"]
@@ -149,10 +154,7 @@ class SocketProvider extends React.Component<Props> {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.remove(documentId);
return;
}
@@ -214,10 +216,7 @@ class SocketProvider extends React.Component<Props> {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
if (err.statusCode === 404 || err.statusCode === 403) {
documents.removeCollectionDocuments(collectionId);
memberships.removeCollectionMemberships(collectionId);
collections.remove(collectionId);
@@ -246,10 +245,7 @@ class SocketProvider extends React.Component<Props> {
force: true,
});
} catch (err) {
if (
err instanceof AuthorizationError ||
err instanceof NotFoundError
) {
if (err.statusCode === 404 || err.statusCode === 403) {
groups.remove(groupId);
}
}
+6 -3
View File
@@ -1,14 +1,17 @@
import { m } from "framer-motion";
import * as React from "react";
import styled, { useTheme } from "styled-components";
import NavLink from "~/components/NavLink";
import NavLinkWithChildrenFunc from "~/components/NavLink";
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
type Props = Omit<
React.ComponentProps<typeof NavLinkWithChildrenFunc>,
"children"
> & {
to: string;
exact?: boolean;
};
const TabLink = styled(NavLink)`
const TabLink = styled(NavLinkWithChildrenFunc)`
position: relative;
display: inline-flex;
align-items: center;
+1 -2
View File
@@ -1,4 +1,3 @@
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";
@@ -73,4 +72,4 @@ const TableFromParams = (
);
};
export default observer(TableFromParams);
export default TableFromParams;
+5 -3
View File
@@ -6,13 +6,15 @@ import useStores from "~/hooks/useStores";
type StoreProps = keyof RootStore;
function withStores<
P extends React.ComponentType<ResolvedProps & RootStore>,
P extends React.ComponentType<React.ComponentProps<P> & RootStore>,
ResolvedProps = JSX.LibraryManagedAttributes<
P,
Omit<React.ComponentProps<P>, StoreProps>
>
>(WrappedComponent: P): React.FC<ResolvedProps> {
const ComponentWithStore = (props: ResolvedProps) => {
>(WrappedComponent: P): React.FC<Omit<ResolvedProps, StoreProps>> {
const ComponentWithStore = (
props: Omit<React.ComponentProps<P>, StoreProps>
) => {
const stores = useStores();
return <WrappedComponent {...(props as any)} {...stores} />;
};
+7 -9
View File
@@ -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 { getEventFiles } from "@shared/utils/files";
import { AttachmentValidation } from "@shared/validations";
import { supportedImageMimeTypes } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
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: (insertNewLine?: boolean) => void;
onClose: () => 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(true);
this.props.onClose();
}
}
@@ -182,9 +182,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
insertItem = (item: any) => {
switch (item.name) {
case "image":
return this.triggerFilePick(
AttachmentValidation.imageContentTypes.join(", ")
);
return this.triggerFilePick(supportedImageMimeTypes.join(", "));
case "attachment":
return this.triggerFilePick("*");
case "embed":
@@ -277,7 +275,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
};
handleFilePicked = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(event);
const files = getDataTransferFiles(event);
const {
view,
@@ -426,7 +424,7 @@ class CommandMenu<T = MenuItem> extends React.Component<Props<T>, State> {
const embedItems: EmbedDescriptor[] = [];
for (const embed of embeds) {
if (embed.title) {
if (embed.title && embed.icon) {
embedItems.push({
...embed,
name: "embed",
+16 -10
View File
@@ -11,7 +11,8 @@ import { setTextSelection } from "prosemirror-utils";
import { EditorView } from "prosemirror-view";
import * as React from "react";
import styled from "styled-components";
import { isInternalUrl, sanitizeHref } from "@shared/utils/urls";
import isUrl from "@shared/editor/lib/isUrl";
import { isInternalUrl } from "@shared/utils/urls";
import Flex from "~/components/Flex";
import { Dictionary } from "~/hooks/useDictionary";
import { ToastOptions } from "~/types";
@@ -44,7 +45,7 @@ type Props = {
href: string,
event: React.MouseEvent<HTMLButtonElement>
) => void;
onShowToast: (message: string, options?: ToastOptions) => void;
onShowToast: (message: string, options: ToastOptions) => void;
view: EditorView;
};
@@ -70,7 +71,7 @@ class LinkEditor extends React.Component<Props, State> {
};
get href(): string {
return sanitizeHref(this.props.mark?.attrs.href) ?? "";
return this.props.mark ? this.props.mark.attrs.href : "";
}
get suggestedLinkTitle(): string {
@@ -113,7 +114,17 @@ class LinkEditor extends React.Component<Props, State> {
this.discardInputValue = true;
const { from, to } = this.props;
href = sanitizeHref(href) ?? "";
// 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}`;
}
this.props.onSelectLink({ href, title, from, to });
};
@@ -229,12 +240,7 @@ class LinkEditor extends React.Component<Props, State> {
handleOpenLink = (event: React.MouseEvent<HTMLButtonElement>): void => {
event.preventDefault();
try {
this.props.onClickLink(this.href, event);
} catch (err) {
this.props.onShowToast(this.props.dictionary.openLinkError);
}
this.props.onClickLink(this.href, event);
};
handleCreateLink = async (value: string) => {
+9 -80
View File
@@ -1,7 +1,6 @@
/* eslint-disable no-irregular-whitespace */
import { darken, lighten, transparentize } from "polished";
import { lighten, transparentize } from "polished";
import styled from "styled-components";
import { depths } from "@shared/styles";
const EditorStyles = styled.div<{
rtl: boolean;
@@ -360,7 +359,6 @@ 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")};
@@ -407,7 +405,6 @@ const EditorStyles = styled.div<{
&.collapsed {
svg {
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
pointer-events: none;
}
transition-delay: 0.1s;
opacity: 1;
@@ -776,45 +773,20 @@ const EditorStyles = styled.div<{
select,
button {
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;
background: ${(props) => props.theme.background};
color: ${(props) => props.theme.text};
border-width: 1px;
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;
}
border-radius: 4px;
padding: 2px 4px;
height: 18px;
}
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;
button {
padding: 2px 4px;
}
&:focus-within,
&:hover {
select {
display: ${(props) => (props.readOnly ? "none" : "inline")};
@@ -831,49 +803,6 @@ 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 {
+1 -8
View File
@@ -554,14 +554,7 @@ export class Editor extends React.PureComponent<
this.setState({ blockMenuOpen: true, blockMenuSearch: search });
};
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();
}
private handleCloseBlockMenu = () => {
if (!this.state.blockMenuOpen) {
return;
}
+9 -15
View File
@@ -14,7 +14,6 @@ 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";
@@ -29,7 +28,6 @@ export default function formattingMenuItems(
const { schema } = state;
const isTable = isInTable(state);
const isList = isInList(state);
const isCode = isInCode(state);
const allowBlocks = !isTable && !isList;
return [
@@ -49,21 +47,19 @@ 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 && !isCode,
visible: !isTemplate,
},
{
name: "code_inline",
@@ -73,7 +69,7 @@ export default function formattingMenuItems(
},
{
name: "separator",
visible: allowBlocks && !isCode,
visible: allowBlocks,
},
{
name: "heading",
@@ -81,7 +77,7 @@ export default function formattingMenuItems(
icon: Heading1Icon,
active: isNodeActive(schema.nodes.heading, { level: 1 }),
attrs: { level: 1 },
visible: allowBlocks && !isCode,
visible: allowBlocks,
},
{
name: "heading",
@@ -89,7 +85,7 @@ export default function formattingMenuItems(
icon: Heading2Icon,
active: isNodeActive(schema.nodes.heading, { level: 2 }),
attrs: { level: 2 },
visible: allowBlocks && !isCode,
visible: allowBlocks,
},
{
name: "blockquote",
@@ -97,11 +93,11 @@ export default function formattingMenuItems(
icon: BlockQuoteIcon,
active: isNodeActive(schema.nodes.blockquote),
attrs: { level: 2 },
visible: allowBlocks && !isCode,
visible: allowBlocks,
},
{
name: "separator",
visible: (allowBlocks || isList) && !isCode,
visible: allowBlocks || isList,
},
{
name: "checkbox_list",
@@ -109,25 +105,24 @@ export default function formattingMenuItems(
icon: TodoListIcon,
keywords: "checklist checkbox task",
active: isNodeActive(schema.nodes.checkbox_list),
visible: (allowBlocks || isList) && !isCode,
visible: allowBlocks || isList,
},
{
name: "bullet_list",
tooltip: dictionary.bulletList,
icon: BulletedListIcon,
active: isNodeActive(schema.nodes.bullet_list),
visible: (allowBlocks || isList) && !isCode,
visible: allowBlocks || isList,
},
{
name: "ordered_list",
tooltip: dictionary.orderedList,
icon: OrderedListIcon,
active: isNodeActive(schema.nodes.ordered_list),
visible: (allowBlocks || isList) && !isCode,
visible: allowBlocks || isList,
},
{
name: "separator",
visible: !isCode,
},
{
name: "link",
@@ -135,7 +130,6 @@ export default function formattingMenuItems(
icon: LinkIcon,
active: isMarkActive(schema.marks.link),
attrs: { href: "" },
visible: !isCode,
},
];
}
+4 -22
View File
@@ -10,7 +10,6 @@ import {
TeamIcon,
BeakerIcon,
DownloadIcon,
WebhooksIcon,
} from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -26,7 +25,6 @@ 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";
@@ -48,7 +46,6 @@ type SettingsPage =
| "Shares"
| "Import"
| "Export"
| "Webhooks"
| "Slack"
| "Zapier";
@@ -149,7 +146,7 @@ const useAuthorizedSettingsConfig = () => {
name: t("Import"),
path: "/settings/import",
component: Import,
enabled: can.createImport,
enabled: can.manage,
group: t("Team"),
icon: NewDocumentIcon,
},
@@ -157,19 +154,11 @@ const useAuthorizedSettingsConfig = () => {
name: t("Export"),
path: "/settings/export",
component: Export,
enabled: can.createExport,
enabled: can.export,
group: t("Team"),
icon: DownloadIcon,
},
// Integrations
Webhooks: {
name: t("Webhooks"),
path: "/settings/webhooks",
component: Webhooks,
enabled: can.createWebhookSubscription,
group: t("Integrations"),
icon: WebhooksIcon,
},
// Intergrations
Slack: {
name: "Slack",
path: "/settings/integrations/slack",
@@ -187,14 +176,7 @@ const useAuthorizedSettingsConfig = () => {
icon: ZapierIcon,
},
}),
[
can.createApiKey,
can.createWebhookSubscription,
can.createExport,
can.createImport,
can.update,
t,
]
[can.createApiKey, can.export, can.manage, can.update, t]
);
const enabledConfigs = React.useMemo(
-4
View File
@@ -18,7 +18,6 @@ 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"),
@@ -53,7 +52,6 @@ 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")}`,
@@ -71,8 +69,6 @@ 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"),
+5 -5
View File
@@ -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 { getEventFiles } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
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.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
async (ev: React.FormEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(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.createExport),
visible: !!(collection && canUserInTeam.export),
onClick: handleExport,
icon: <ExportIcon />,
},
@@ -296,7 +296,7 @@ function CollectionMenu({
alphabeticalSort,
handleEdit,
handlePermissions,
canUserInTeam.createExport,
canUserInTeam.export,
handleExport,
handleDelete,
handleChangeSort,
+4 -4
View File
@@ -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 { getEventFiles } from "@shared/utils/files";
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
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.ChangeEvent<HTMLInputElement>) => {
const files = getEventFiles(ev);
async (ev: React.FormEvent<HTMLInputElement>) => {
const files = getDataTransferFiles(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 && can.update && (
{showDisplayOptions && !isMobile && (
<Style>
<ToggleMenuItem
width={26}
+1 -1
View File
@@ -1,5 +1,5 @@
import { computed, observable } from "mobx";
import type { Role } from "@shared/types";
import { Role } from "@shared/types";
import ParanoidModel from "./ParanoidModel";
import Field from "./decorators/Field";
-27
View File
@@ -1,27 +0,0 @@
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;
+6 -24
View File
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
import Archive from "~/scenes/Archive";
@@ -12,8 +11,6 @@ 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(
@@ -62,10 +59,7 @@ const RedirectDocument = ({
/>
);
function AuthenticatedRoutes() {
const team = useCurrentTeam();
const can = usePolicy(team.id);
export default function AuthenticatedRoutes() {
return (
<SocketProvider>
<Layout>
@@ -77,24 +71,14 @@ 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} />
@@ -119,5 +103,3 @@ function AuthenticatedRoutes() {
</SocketProvider>
);
}
export default observer(AuthenticatedRoutes);
-2
View File
@@ -3,7 +3,6 @@ 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";
@@ -94,7 +93,6 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
type="text"
label={t("Name")}
onChange={handleNameChange}
maxLength={CollectionValidation.maxNameLength}
value={name}
required
autoFocus
+1 -5
View File
@@ -3,9 +3,6 @@ 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";
@@ -32,7 +29,7 @@ class CollectionNew extends React.Component<Props> {
icon = "";
@observable
color = randomElement(colorPalette);
color = "#4E5C6E";
@observable
sharing = true;
@@ -130,7 +127,6 @@ class CollectionNew extends React.Component<Props> {
type="text"
label={t("Name")}
onChange={this.handleNameChange}
maxLength={CollectionValidation.maxNameLength}
value={this.name}
required
autoFocus
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -104,4 +103,4 @@ const Select = styled(InputSelect)`
}
` as React.ComponentType<SelectProps>;
export default observer(MemberListItem);
export default MemberListItem;
@@ -1,4 +1,3 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
@@ -46,4 +45,4 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
);
};
export default observer(UserListItem);
export default UserListItem;
+3 -36
View File
@@ -2,21 +2,16 @@ 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 styled, { useTheme } from "styled-components";
import { setCookie } from "tiny-cookie";
import { useTheme } from "styled-components";
import DocumentModel from "~/models/Document";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import Layout from "~/components/Layout";
import Sidebar from "~/components/Sidebar/Shared";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { NavigationNode } from "~/types";
import { AuthorizationError, OfflineError } from "~/utils/errors";
import isCloudHosted from "~/utils/isCloudHosted";
import Login from "../Login";
import { OfflineError } from "~/utils/errors";
import Document from "./components/Document";
import Loading from "./components/Loading";
@@ -78,7 +73,6 @@ function SharedDocumentScene(props: Props) {
const { ui } = useStores();
const theme = useTheme();
const location = useLocation();
const { t } = useTranslation();
const [response, setResponse] = React.useState<Response>();
const [error, setError] = React.useState<Error | null | undefined>();
const { documents } = useStores();
@@ -111,29 +105,7 @@ function SharedDocumentScene(props: Props) {
}, [documents, documentSlug, shareId, ui]);
if (error) {
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 />;
}
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
if (!response) {
@@ -165,9 +137,4 @@ function SharedDocumentScene(props: Props) {
);
}
const GetStarted = styled(Text)`
text-align: center;
margin-top: -8px;
`;
export default observer(SharedDocumentScene);
+195 -130
View File
@@ -1,12 +1,14 @@
import invariant from "invariant";
import { observable } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
import { RouteComponentProps, StaticContext } from "react-router";
import RootStore from "~/stores/RootStore";
import Document from "~/models/Document";
import Revision from "~/models/Revision";
import Error404 from "~/scenes/Error404";
import ErrorOffline from "~/scenes/ErrorOffline";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import withStores from "~/components/withStores";
import { NavigationNode } from "~/types";
import { NotFoundError, OfflineError } from "~/utils/errors";
import history from "~/utils/history";
@@ -14,104 +16,146 @@ import { matchDocumentEdit } from "~/utils/routeHelpers";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
type Params = {
documentSlug: string;
revisionId?: string;
shareId?: string;
};
type LocationState = {
title?: string;
restore?: boolean;
revisionId?: string;
};
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;
type Props = RouteComponentProps<Params, StaticContext, LocationState> & {
children: Children;
};
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;
// Allows loading by /doc/slug-<urlId> or /doc/<id>
const document =
documents.getByUrl(match.params.documentSlug) ??
documents.get(match.params.documentSlug);
const revision = revisionId ? revisions.get(revisionId) : undefined;
const sharedTree = document
? documents.getSharedTree(document.id)
: undefined;
const isEditRoute = match.path === matchDocumentEdit;
const isEditing = isEditRoute || !!auth.team?.collaborativeEditing;
const can = usePolicy(document ? document.id : "");
const location = useLocation<LocationState>();
React.useEffect(() => {
async function fetchDocument() {
try {
await documents.fetchWithSharedTree(documentSlug, {
shareId,
});
} catch (err) {
setError(err);
}
}
fetchDocument();
}, [ui, documents, document, shareId, documentSlug]);
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;
type Props = RootStore &
RouteComponentProps<
{
documentSlug: string;
revisionId?: string;
shareId?: string;
title?: string;
},
[document, documents]
);
StaticContext,
{
title?: string;
}
> & {
children: (arg0: any) => React.ReactNode;
};
@observer
class DataLoader extends React.Component<Props> {
sharedTree: NavigationNode | null | undefined;
@observable
document: Document | null | undefined;
@observable
revision: Revision | null | undefined;
@observable
shapshot: Blob | null | undefined;
@observable
error: Error | null | undefined;
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();
}
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,
{
shareId,
}
);
this.sharedTree = response.sharedTree;
this.document = response.document;
if (revisionId && revisionId !== "latest") {
await this.loadRevision();
} else {
this.revision = undefined;
}
} catch (err) {
this.error = err;
return;
}
const document = this.document;
React.useEffect(() => {
if (document) {
// sets the current document as active in the sidebar
ui.setActiveDocument(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);
// If we're attempting to update an archived, deleted, or otherwise
// uneditable document then forward to the canonical read url.
if (!can.update && isEditRoute) {
if (!can.update && this.isEditRoute) {
history.push(document.url);
return;
}
@@ -119,51 +163,72 @@ function DataLoader({ match, children }: Props) {
// Prevents unauthorized request to load share information for the document
// when viewing a public share link
if (can.read) {
shares.fetch(document.id).catch((err) => {
this.props.shares.fetch(document.id).catch((err) => {
if (!(err instanceof NotFoundError)) {
throw err;
}
});
}
}
}, [can.read, can.update, document, isEditRoute, shares, ui]);
};
if (error) {
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
}
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 (!document || !team || (revisionId && !revision)) {
return (
<>
<Loading location={location} />
{isEditing && !team?.collaborativeEditing && <HideSidebar ui={ui} />}
</>
<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>
);
}
// 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 observer(DataLoader);
export default withStores(DataLoader);
+5 -13
View File
@@ -55,21 +55,13 @@ 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<Params, StaticContext, LocationState> & {
RouteComponentProps<
Record<string, string>,
StaticContext,
{ restore?: boolean; revisionId?: string }
> & {
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={DocumentValidation.maxTitleLength}
maxLength={MAX_TITLE_LENGTH}
readOnly={readOnly}
dir="auto"
ref={ref}
+1 -3
View File
@@ -57,10 +57,8 @@ 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(() => {
setTimeout(() => props.onSave({ autosave: true }), 250);
props.onSave({ autosave: true });
}, [props]);
const handleGoToNextInput = React.useCallback(
@@ -7,7 +7,6 @@ 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";
@@ -18,12 +17,10 @@ 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 =
team.sharing &&
(share?.published || (sharedParent?.published && !document.isDraft));
share?.published || (sharedParent?.published && !document.isDraft);
const popover = usePopoverState({
gutter: 0,
@@ -14,7 +14,6 @@ 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";
@@ -37,9 +36,8 @@ function SharePopover({
onRequestClose,
visible,
}: Props) {
const team = useCurrentTeam();
const { t } = useTranslation();
const { shares } = useStores();
const { shares, auth } = useStores();
const { showToast } = useToasts();
const [isCopied, setIsCopied] = React.useState(false);
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
@@ -49,23 +47,22 @@ function SharePopover({
const canPublish =
can.update &&
!document.isTemplate &&
team.sharing &&
auth.team?.sharing &&
documentAbilities.share;
const isPubliclyShared =
team.sharing &&
((share && share.published) ||
(sharedParent && sharedParent.published && !document.isDraft));
(share && share.published) ||
(sharedParent && sharedParent.published && !document.isDraft);
useKeyDown("Escape", onRequestClose);
React.useEffect(() => {
if (visible && team.sharing) {
if (visible) {
document.share();
buttonRef.current?.focus();
}
return () => (timeout.current ? clearTimeout(timeout.current) : undefined);
}, [document, visible, team.sharing]);
}, [document, visible]);
const handlePublishedChange = React.useCallback(
async (event) => {
@@ -116,9 +113,6 @@ function SharePopover({
const userLocale = useUserLocale();
const locale = userLocale ? dateLocale(userLocale) : undefined;
const shareUrl = team.sharing
? share?.url ?? ""
: `${team.url}${document.url}`;
return (
<>
@@ -205,14 +199,14 @@ function SharePopover({
type="text"
label={t("Link")}
placeholder={`${t("Loading")}`}
value={shareUrl}
value={share ? share.url : ""}
labelHidden
readOnly
/>
<CopyToClipboard text={shareUrl} onCopy={handleCopied}>
<CopyToClipboard text={share ? share.url : ""} onCopy={handleCopied}>
<Button
type="submit"
disabled={isCopied || (!share && team.sharing)}
disabled={isCopied || !share}
ref={buttonRef}
primary
>
+9 -17
View File
@@ -7,21 +7,13 @@ import DataLoader from "./components/DataLoader";
import Document from "./components/Document";
import SocketPresence from "./components/SocketPresence";
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) {
export default function DocumentScene(
props: RouteComponentProps<
{ documentSlug: string; revisionId: string },
StaticContext,
{ title?: string }
>
) {
const { ui } = useStores();
const team = useCurrentTeam();
const { documentSlug, revisionId } = props.match.params;
@@ -55,12 +47,12 @@ export default function DocumentScene(props: Props) {
if (isActive && !isMultiplayer) {
return (
<SocketPresence documentId={document.id} isEditing={isEditing}>
<Document document={document} {...rest} />
<Document document={document} match={props.match} {...rest} />
</SocketPresence>
);
}
return <Document document={document} {...rest} />;
return <Document document={document} match={props.match} {...rest} />;
}}
</DataLoader>
);
+1 -16
View File
@@ -24,9 +24,6 @@ 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();
@@ -97,23 +94,11 @@ 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</em>."
values={{
documentTitle: document.titleWithDefault,
}}
components={{
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>."
defaults="Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and any nested documents."
values={{
documentTitle: document.titleWithDefault,
any: nestedDocumentsCount,
}}
components={{
em: <strong />,
@@ -1,6 +1,4 @@
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";
@@ -17,22 +15,20 @@ 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>
</>
) : (
t("Never signed in")
"Never signed in"
)}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</>
}
image={<Avatar src={user.avatarUrl} size={32} />}
@@ -41,7 +37,7 @@ const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
{onRemove && <GroupMemberMenu onRemove={onRemove} />}
{onAdd && (
<Button onClick={onAdd} neutral>
{t("Add")}
Add
</Button>
)}
</Flex>
@@ -50,4 +46,4 @@ const GroupMemberListItem = ({ user, onRemove, onAdd }: Props) => {
);
};
export default observer(GroupMemberListItem);
export default GroupMemberListItem;
@@ -1,7 +1,5 @@
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";
@@ -16,8 +14,6 @@ type Props = {
};
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={user.name}
@@ -25,20 +21,20 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
subtitle={
<>
{user.lastActiveAt ? (
<Trans>
<>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
</>
) : (
t("Never signed in")
"Never signed in"
)}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
{user.isInvited && <Badge>Invited</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>Admin</Badge>}
</>
}
actions={
canEdit ? (
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
{t("Add")}
Add
</Button>
) : undefined
}
@@ -46,4 +42,4 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
);
};
export default observer(UserListItem);
export default UserListItem;
+5 -9
View File
@@ -89,15 +89,11 @@ function AuthenticationProvider(props: Props) {
);
}
// 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;
// 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}`;
return (
<Wrapper>
-16
View File
@@ -18,13 +18,6 @@ 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
@@ -57,15 +50,6 @@ 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
+6 -13
View File
@@ -49,11 +49,7 @@ function Header({ config }: { config?: Config | undefined }) {
);
}
type Props = {
children?: (config?: Config) => React.ReactNode;
};
function Login({ children }: Props) {
function Login() {
const location = useLocation();
const query = useQuery();
const { t, i18n } = useTranslation();
@@ -178,14 +174,11 @@ function Login({ children }: Props) {
</GetStarted>
</>
) : (
<>
<StyledHeading centered>
{t("Login to {{ authProviderName }}", {
authProviderName: config.name || "Outline",
})}
</StyledHeading>
{children?.(config)}
</>
<StyledHeading centered>
{t("Login to {{ authProviderName }}", {
authProviderName: config.name || "Outline",
})}
</StyledHeading>
)}
<Notices />
{defaultProvider && (
-7
View File
@@ -44,13 +44,6 @@ 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",
+104 -125
View File
@@ -36,17 +36,9 @@ 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(
@@ -59,13 +51,17 @@ 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",
});
@@ -81,21 +77,6 @@ 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 });
@@ -103,26 +84,26 @@ function Security() {
[data, saveData]
);
const handleInviteRequiredChange = React.useCallback(
const handleAllowSignupsChange = 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 require invites?"),
title: t("Are you sure you want to disable authorized signups?"),
content: (
<ConfirmationDialog
onSubmit={async () => {
await saveData(newData);
}}
submitText={t("Im sure")}
savingText={`${t("Saving")}`}
submitText={t("Im sure — Disable")}
savingText={`${t("Disabling")}`}
danger
>
<Trans
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."
defaults="New account creation using <em>{{ authenticationMethods }}</em> will be disabled. New users will need to be invited."
values={{
authenticationMethods,
}}
@@ -142,41 +123,34 @@ function Security() {
);
const handleRemoveDomain = async (index: number) => {
const newDomains = allowedDomains.filter((_, i) => index !== i);
const newData = {
...data,
};
newData.allowedDomains && newData.allowedDomains.splice(index, 1);
setAllowedDomains(newDomains);
const touchedExistingDomain = index < lastKnownDomainCount;
if (touchedExistingDomain) {
setExistingDomainsTouched(true);
}
setData(newData);
setDomainsChanged(true);
};
const handleAddDomain = () => {
const newDomains = [...allowedDomains, ""];
const newData = {
...data,
allowedDomains: [...(data.allowedDomains || []), ""],
};
setAllowedDomains(newDomains);
setData(newData);
};
const createOnDomainChangedHandler = (index: number) => (
ev: React.ChangeEvent<HTMLInputElement>
) => {
const newDomains = allowedDomains.slice();
const newData = { ...data };
newDomains[index] = ev.currentTarget.value;
setAllowedDomains(newDomains);
const touchedExistingDomain = index < lastKnownDomainCount;
if (touchedExistingDomain) {
setExistingDomainsTouched(true);
}
newData.allowedDomains![index] = ev.currentTarget.value;
setData(newData);
setDomainsChanged(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>
@@ -240,57 +214,63 @@ function Security() {
</SettingRow>
{isCloudHosted && (
<SettingRow
label={t("Require invites")}
name="inviteRequired"
description={t(
"Require members to be invited to the team before they can create an account using SSO."
)}
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 />,
}}
/>
}
>
<Switch
id="inviteRequired"
checked={data.inviteRequired}
onChange={handleInviteRequiredChange}
id="allowSignups"
checked={!data.inviteRequired}
onChange={handleAllowSignupsChange}
/>
</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("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("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) => (
<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) => (
<Flex key={index} gap={4}>
<Input
key={index}
@@ -312,36 +292,35 @@ function Security() {
</Flex>
))}
<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 />
)}
<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 />
)}
{showSaveChanges && (
<Fade>
<Button
type="button"
onClick={handleSaveDomains}
disabled={auth.isSaving}
>
<Trans>Save changes</Trans>
</Button>
</Fade>
)}
</Flex>
</SettingRow>
)}
{domainsChanged && (
<Fade>
<Button
type="button"
onClick={handleChange}
disabled={auth.isSaving}
>
<Trans>Save changes</Trans>
</Button>
</Fade>
)}
</Flex>
</SettingRow>
</Scene>
);
}
-69
View File
@@ -1,69 +0,0 @@
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,7 +5,6 @@ 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";
@@ -135,7 +134,7 @@ class ImageUpload extends React.Component<RootStore & Props> {
return (
<Dropzone
accept={AttachmentValidation.avatarContentTypes.join(", ")}
accept="image/png, image/jpeg"
onDropAccepted={this.onDropAccepted}
>
{({ getRootProps, getInputProps }) => (
+28 -26
View File
@@ -1,6 +1,5 @@
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";
@@ -14,31 +13,34 @@ type Props = {
showMenu: boolean;
};
const UserListItem = ({ user, showMenu }: Props) => {
const { t } = useTranslation();
@observer
class UserListItem extends React.Component<Props> {
render() {
const { user, showMenu } = this.props;
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}
/>
);
};
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}
/>
);
}
}
const Title = styled.span`
&:hover {
@@ -47,4 +49,4 @@ const Title = styled.span`
}
`;
export default observer(UserListItem);
export default UserListItem;
@@ -1,9 +1,6 @@
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;
@@ -12,41 +9,34 @@ type Props = {
const UserStatusFilter = ({ activeKey, onSelect, ...rest }: Props) => {
const { t } = useTranslation();
const user = useCurrentUser();
const options = React.useMemo(
() =>
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]
() => [
{
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]
);
return (
@@ -60,4 +50,4 @@ const UserStatusFilter = ({ activeKey, onSelect, ...rest }: Props) => {
);
};
export default observer(UserStatusFilter);
export default UserStatusFilter;
@@ -1,34 +0,0 @@
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>
);
}
@@ -1,57 +0,0 @@
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;
@@ -1,286 +0,0 @@
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;
@@ -1,89 +0,0 @@
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;
@@ -1,51 +0,0 @@
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;
+28 -83
View File
@@ -1,120 +1,65 @@
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 [isWaitingCode, setWaitingCode] = React.useState(false);
const [isDeleting, setIsDeleting] = React.useState(false);
const { auth } = useStores();
const { showToast } = useToasts();
const { t } = useTranslation();
const { register, handleSubmit: formHandleSubmit, formState } = useForm<
FormData
>();
const handleRequestDelete = React.useCallback(
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
try {
await auth.requestDelete();
setWaitingCode(true);
} catch (error) {
showToast(error.message, {
type: "error",
});
}
},
[auth, showToast]
);
const handleSubmit = React.useCallback(
async (data: FormData) => {
async (ev: React.SyntheticEvent) => {
ev.preventDefault();
setIsDeleting(true);
try {
await auth.deleteUser(data);
await auth.deleteUser();
auth.logout();
} catch (error) {
showToast(error.message, {
type: "error",
});
} finally {
setIsDeleting(false);
}
},
[auth, showToast]
);
const inputProps = register("code", {
required: true,
});
return (
<Modal isOpen title={t("Delete Account")} onRequestClose={onRequestClose}>
<Flex column>
<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 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>
</Flex>
</Modal>
+11 -15
View File
@@ -199,17 +199,11 @@ export default class AuthStore {
};
@action
requestDelete = () => {
return client.post(`/users.requestDelete`);
};
@action
deleteUser = async (data: { code: string }) => {
await client.post(`/users.delete`, data);
deleteUser = async () => {
await client.post(`/users.delete`);
runInAction("AuthStore#updateUser", () => {
this.user = null;
this.team = null;
this.policies = [];
this.token = null;
});
};
@@ -242,7 +236,6 @@ export default class AuthStore {
collaborativeEditing?: boolean;
defaultCollectionId?: string | null;
subdomain?: string | null | undefined;
allowedDomains?: string[] | null | undefined;
}) => {
this.isSaving = true;
@@ -266,6 +259,14 @@ 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) {
@@ -289,12 +290,7 @@ 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;
};
}
+1 -2
View File
@@ -7,7 +7,6 @@ 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 };
@@ -210,7 +209,7 @@ export default abstract class BaseStore<T extends BaseModel> {
this.addPolicies(res.policies);
return this.add(res.data);
} catch (err) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
if (err.statusCode === 403) {
this.remove(id);
}
+1 -2
View File
@@ -4,7 +4,6 @@ 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";
@@ -159,7 +158,7 @@ export default class CollectionsStore extends BaseStore<Collection> {
this.addPolicies(res.policies);
return this.add(res.data);
} catch (err) {
if (err instanceof AuthorizationError || err instanceof NotFoundError) {
if (err.statusCode === 403) {
this.remove(id);
}
+2 -2
View File
@@ -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,
DocumentValidation.maxTitleLength - append.length
MAX_TITLE_LENGTH - append.length
)}${append}`,
text: document.text,
});
-4
View File
@@ -22,7 +22,6 @@ 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;
@@ -49,7 +48,6 @@ export default class RootStore {
views: ViewsStore;
toasts: ToastsStore;
fileOperations: FileOperationsStore;
webhookSubscriptions: WebhookSubscriptionsStore;
constructor() {
// PoliciesStore must be initialized before AuthStore
@@ -77,7 +75,6 @@ export default class RootStore {
this.views = new ViewsStore(this);
this.fileOperations = new FileOperationsStore(this);
this.toasts = new ToastsStore();
this.webhookSubscriptions = new WebhookSubscriptionsStore(this);
}
logout() {
@@ -103,6 +100,5 @@ export default class RootStore {
// this.ui omitted to keep ui settings between sessions
this.users.clear();
this.views.clear();
this.webhookSubscriptions.clear();
}
}
+1
View File
@@ -59,6 +59,7 @@ export default class SharesStore extends BaseStore<Share> {
try {
const res = await client.post(`/${this.modelName}s.info`, {
documentId,
apiVersion: 2,
});
if (isUndefined(res)) {
+1 -1
View File
@@ -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 type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import Storage from "~/utils/Storage";
const UI_STORE = "UI_STORE";
-18
View File
@@ -1,18 +0,0 @@
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
View File
@@ -13,4 +13,4 @@ Enzyme.configure({
global.localStorage = localStorage;
require("jest-fetch-mock").enableMocks();
require("jest-fetch-mock").enableMocks();
+2 -2
View File
@@ -1,4 +1,4 @@
import { Location, LocationDescriptor } from "history";
import { Location } 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: LocationDescriptor;
to: string;
visible?: boolean;
selected?: boolean;
disabled?: boolean;
+6 -1
View File
@@ -141,11 +141,16 @@ 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 || "";
@@ -181,7 +186,7 @@ class ApiClient {
throw new ServiceUnavailableError(error.message);
}
throw new RequestError(`Error ${response.status}: ${error.message}`);
throw new RequestError(`Error ${error.statusCode}: ${error.message}`);
};
get = (
+1 -4
View File
@@ -8,10 +8,7 @@ export async function loadPolyfills() {
if (!supportsResizeObserver()) {
polyfills.push(
import(
/* webpackChunkName: "resize-observer" */
"@juggle/resize-observer"
).then((module) => {
import("@juggle/resize-observer").then((module) => {
window.ResizeObserver = module.ResizeObserver;
})
);
+1 -1
View File
@@ -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.
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.
To report a security issue, email [hello@getoutline.com](mailto:hello@getoutline.com) and include the word "SECURITY" in the subject line.
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.
+42 -47
View File
@@ -16,13 +16,11 @@
"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",
@@ -44,14 +42,14 @@
"> 0.25%, not dead"
],
"dependencies": {
"@babel/core": "^7.18.6",
"@babel/core": "^7.16.0",
"@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": "^4.0.0",
"@bull-board/koa": "^4.0.0",
"@bull-board/api": "^3.11.1",
"@bull-board/koa": "^3.11.1",
"@dnd-kit/core": "^4.0.3",
"@dnd-kit/modifiers": "^4.0.0",
"@dnd-kit/sortable": "^5.1.0",
@@ -60,7 +58,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.2.1",
"@outlinewiki/koa-passport": "^4.1.4",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@renderlesskit/react": "^0.6.0",
"@sentry/node": "^6.3.1",
@@ -69,7 +67,6 @@
"@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",
@@ -100,7 +97,7 @@
"fs-extra": "^4.0.2",
"fuzzy-search": "^3.2.1",
"gemoji": "6.x",
"http-errors": "2.0.0",
"http-errors": "1.4.0",
"i18next": "^20.6.1",
"i18next-http-backend": "^1.3.2",
"immutable": "^4.0.0",
@@ -109,17 +106,18 @@
"is-printable-key-event": "^1.0.0",
"json-loader": "0.5.4",
"jsonwebtoken": "^8.5.0",
"jszip": "^3.10.0",
"jszip": "^3.7.1",
"kbar": "0.1.0-beta.28",
"koa": "^2.13.4",
"koa": "^2.10.0",
"koa-body": "^4.2.0",
"koa-compress": "^5.1.0",
"koa-convert": "^2.0.0",
"koa-helmet": "^6.1.0",
"koa-compress": "2.0.0",
"koa-convert": "1.2.0",
"koa-helmet": "5.2.0",
"koa-jwt": "^3.6.0",
"koa-logger": "^3.2.1",
"koa-mount": "^3.0.0",
"koa-onerror": "^4.2.0",
"koa-router": "7.4.0",
"koa-onerror": "^4.1.0",
"koa-router": "7.0.1",
"koa-send": "5.0.1",
"koa-sslify": "2.1.2",
"koa-static": "^4.0.1",
@@ -128,25 +126,24 @@
"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",
"node-htmldiff": "^0.9.3",
"nodemailer": "^6.6.1",
"outline-icons": "^1.43.1",
"outline-icons": "^1.42.0",
"oy-vey": "^0.10.0",
"passport": "^0.6.0",
"passport": "^0.4.1",
"passport-google-oauth2": "^0.2.0",
"passport-oauth2": "^1.6.1",
"passport-slack-oauth2": "^1.1.1",
"passport-slack-oauth2": "^1.1.0",
"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.3.1",
"prosemirror-gapcursor": "^1.2.1",
"prosemirror-history": "^1.2.0",
"prosemirror-inputrules": "^1.1.3",
"prosemirror-keymap": "^1.1.5",
@@ -157,7 +154,7 @@
"prosemirror-tables": "^1.1.1",
"prosemirror-transform": "1.2.5",
"prosemirror-utils": "^0.9.6",
"prosemirror-view": "1.26.5",
"prosemirror-view": "1.23.6",
"query-string": "^7.0.1",
"quoted-printable": "^1.0.1",
"randomstring": "1.1.5",
@@ -166,11 +163,10 @@
"react-avatar-editor": "^11.1.0",
"react-color": "^2.17.3",
"react-dnd": "^14.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dnd-html5-backend": "^14.0.0",
"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",
@@ -179,25 +175,24 @@
"react-table": "^7.7.0",
"react-virtualized-auto-sizer": "^1.0.5",
"react-waypoint": "^10.1.0",
"react-window": "^1.8.7",
"react-window": "^1.8.6",
"reakit": "^1.3.10",
"reflect-metadata": "^0.1.13",
"refractor": "^3.5.0",
"regenerator-runtime": "^0.13.7",
"request-filtering-agent": "^1.1.2",
"semver": "^7.3.7",
"semver": "^7.3.2",
"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": "^5.3.0",
"slug": "^4.0.4",
"slugify": "^1.6.5",
"smooth-scroll-into-view-if-needed": "^1.1.32",
"socket.io": "^3.1.2",
"socket.io-client": "^3.1.3",
"socket.io-redis": "^6.1.0",
"socket.io": "^2.4.0",
"socket.io-redis": "^5.4.0",
"socketio-auth": "^0.1.1",
"stoppable": "^1.1.0",
"string-replace-to-array": "^1.0.3",
"styled-components": "^5.2.3",
@@ -214,7 +209,7 @@
"winston": "^3.3.3",
"ws": "^7.5.3",
"y-indexeddb": "^9.0.6",
"yjs": "^13.5.39"
"yjs": "^13.5.34"
},
"devDependencies": {
"@babel/cli": "^7.10.5",
@@ -228,29 +223,28 @@
"@types/emoji-regex": "^9.2.0",
"@types/enzyme": "^3.10.10",
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/formidable": "^2.0.5",
"@types/formidable": "^2.0.0",
"@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.8",
"@types/jsonwebtoken": "^8.5.5",
"@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": "^2.1.0",
"@types/koa-sslify": "^4.0.2",
"@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": "18.0.6",
"@types/node-fetch": "^2.6.2",
"@types/node": "15.12.2",
"@types/nodemailer": "^6.4.4",
"@types/passport-oauth2": "^1.4.11",
"@types/prosemirror-commands": "^1.0.1",
@@ -278,9 +272,11 @@
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/refractor": "^3.0.2",
"@types/semver": "^7.3.10",
"@types/semver": "^7.3.9",
"@types/sequelize": "^4.28.10",
"@types/slug": "^5.0.3",
"@types/slug": "^5.0.2",
"@types/socket.io": "2.1.13",
"@types/socket.io-parser": "^2.2.1",
"@types/stoppable": "^1.1.1",
"@types/styled-components": "^5.1.15",
"@types/throng": "^5.0.3",
@@ -296,11 +292,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": "^7.3.0",
"concurrently": "^6.2.1",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.6",
"eslint": "^7.6.0",
"eslint-config-prettier": "^8.5.0",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-import": "^2.25.3",
@@ -318,12 +314,12 @@
"koa-webpack-dev-middleware": "^1.4.5",
"koa-webpack-hot-middleware": "^1.0.3",
"lint-staged": "^12.3.8",
"nodemon": "^2.0.18",
"nodemon": "^2.0.15",
"prettier": "^2.0.5",
"react-refresh": "^0.9.0",
"rimraf": "^2.5.4",
"terser-webpack-plugin": "^4.1.0",
"typescript": "^4.7.4",
"typescript": "^4.4.4",
"url-loader": "^0.6.2",
"webpack": "4.44.1",
"webpack-cli": "^3.3.12",
@@ -333,12 +329,11 @@
"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",
"jpeg-js": "0.4.4"
"js-yaml": "^3.14.1"
},
"version": "0.65.2"
"version": "0.64.3"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 B

+1 -15
View File
@@ -24,19 +24,5 @@
}
],
"tsconfig-paths-module-resolver"
],
"env": {
"production": {
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
},
"development": {
"ignore": [
"**/__mocks__",
"**/*.test.ts"
]
}
}
]
}
-9
View File
@@ -1,9 +0,0 @@
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;
+3 -7
View File
@@ -30,11 +30,7 @@ export default class MetricsExtension implements Extension {
});
Metrics.gaugePerInstance(
"collaboration.connections_count",
instance.getConnectionsCount() + 1
);
Metrics.gaugePerInstance(
"collaboration.documents_count",
instance.getDocumentsCount()
instance.getConnectionsCount()
);
}
@@ -47,8 +43,8 @@ export default class MetricsExtension implements Extension {
instance.getConnectionsCount()
);
Metrics.gaugePerInstance(
"collaboration.documents_count",
instance.getDocumentsCount()
"collaboration.documents_count", // -1 adjustment because hook is called before document is removed
instance.getDocumentsCount() - 1
);
}
+2 -1
View File
@@ -3,6 +3,7 @@ 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";
@@ -29,11 +30,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,44 +108,6 @@ 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");
+11 -66
View File
@@ -3,63 +3,37 @@ 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 { AuthenticationProvider, Collection, Team, User } from "@server/models";
import teamProvisioner from "./teamProvisioner";
import userProvisioner from "./userProvisioner";
import { Collection, Team, User } from "@server/models";
import teamCreator from "./teamCreator";
import userCreator from "./userCreator";
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;
};
};
@@ -79,45 +53,17 @@ async function accountProvisioner({
authentication: authenticationParams,
}: Props): Promise<AccountProvisionerResult> {
let result;
let emailMatchOnly;
try {
result = await teamProvisioner({
...teamParams,
result = await teamCreator({
name: teamParams.name,
domain: teamParams.domain,
subdomain: teamParams.subdomain,
avatarUrl: teamParams.avatarUrl,
authenticationProvider: authenticationProviderParams,
ip,
});
} catch (err) {
// 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);
}
throw AuthenticationError(err.message);
}
invariant(result, "Team creator result must exist");
@@ -128,21 +74,20 @@ async function accountProvisioner({
}
try {
const result = await userProvisioner({
const result = await userCreator({
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,4 +1,5 @@
import { yDocToProsemirrorJSON } from "@getoutline/y-prosemirror";
import invariant from "invariant";
import { uniq } from "lodash";
import { Node } from "prosemirror-model";
import * as Y from "yjs";
@@ -24,9 +25,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"));
+5 -2
View File
@@ -1,3 +1,4 @@
import invariant from "invariant";
import { Transaction } from "sequelize";
import { Document, Event, User } from "@server/models";
@@ -104,12 +105,14 @@ 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
return await Document.findOne({
const doc = await Document.findOne({
where: {
id: document.id,
publishedAt: document.publishedAt,
},
rejectOnEmpty: true,
transaction,
});
invariant(doc, "Document must exist");
return doc;
}
-15
View File
@@ -148,21 +148,6 @@ 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