mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c584096d59 | |||
| 5a3d55bc58 | |||
| eaff7d933e | |||
| 21b378b80d | |||
| c143036374 | |||
| a773516e01 | |||
| c7045b0c00 | |||
| 53d0cdd151 | |||
| a22e50cd3d | |||
| 00f65ce29d | |||
| da8936e9d8 |
+6
-10
@@ -16,15 +16,7 @@ DATABASE_CONNECTION_POOL_MIN=
|
||||
DATABASE_CONNECTION_POOL_MAX=
|
||||
# Uncomment this to disable SSL for connecting to Postgres
|
||||
# PGSSLMODE=disable
|
||||
|
||||
# For redis you can either specify an ioredis compatible url like this
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# or alternatively, if you would like to provide addtional connection options,
|
||||
# use a base64 encoded JSON connection option object. Refer to the ioredis documentation
|
||||
# for a list of available options.
|
||||
# Example: Use Redis Sentinel for high availability
|
||||
# {"sentinels":[{"host":"sentinel-0","port":26379},{"host":"sentinel-1","port":26379}],"name":"mymaster"}
|
||||
# REDIS_URL=ioredis://eyJzZW50aW5lbHMiOlt7Imhvc3QiOiJzZW50aW5lbC0wIiwicG9ydCI6MjYzNzl9LHsiaG9zdCI6InNlbnRpbmVsLTEiLCJwb3J0IjoyNjM3OX1dLCJuYW1lIjoibXltYXN0ZXIifQ==
|
||||
|
||||
# URL should point to the fully qualified, publicly accessible URL. If using a
|
||||
# proxy the port in URL and PORT may be different.
|
||||
@@ -65,8 +57,8 @@ AWS_S3_ACL=private
|
||||
#
|
||||
# When configuring the Client ID, add a redirect URL under "OAuth & Permissions":
|
||||
# https://<URL>/auth/slack.callback
|
||||
SLACK_CLIENT_ID=get_a_key_from_slack
|
||||
SLACK_CLIENT_SECRET=get_the_secret_of_above_key
|
||||
SLACK_KEY=get_a_key_from_slack
|
||||
SLACK_SECRET=get_the_secret_of_above_key
|
||||
|
||||
# To configure Google auth, you'll need to create an OAuth Client ID at
|
||||
# => https://console.cloud.google.com/apis/credentials
|
||||
@@ -137,6 +129,10 @@ MAXIMUM_IMPORT_SIZE=5120000
|
||||
# requests and this ends up being duplicative
|
||||
DEBUG=http
|
||||
|
||||
# Comma separated list of domains to be allowed to signin to the wiki. If not
|
||||
# set, all domains are allowed by default when using Google OAuth to signin
|
||||
ALLOWED_DOMAINS=
|
||||
|
||||
# For a complete Slack integration with search and posting to channels the
|
||||
# following configs are also needed, some more details
|
||||
# => https://wiki.generaloutline.com/share/be25efd1-b3ef-4450-b8e5-c4a4fc11e02a
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 120
|
||||
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 14
|
||||
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- security
|
||||
- pinned
|
||||
|
||||
# Label to use when marking an issue as stale
|
||||
staleLabel: stale
|
||||
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
Hey! The issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed soon if no further activity occurs. Please
|
||||
reply here if you wish for the issue to be kept open.
|
||||
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -67,4 +67,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
name: "Close Stale PRs"
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
stale-pr-message: "This PR is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
stale-issue-message: "This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 5 days"
|
||||
close-pr-message: "Automatically closed due to inactivity"
|
||||
close-issue-message: "Automatically closed due to inactivity"
|
||||
days-before-issue-stale: 120
|
||||
days-before-pr-stale: 60
|
||||
days-before-close: 5
|
||||
operations-per-run: 60
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: "security,pinned"
|
||||
- name: Print outputs
|
||||
run: echo ${{ join(steps.stale.outputs.*, ',') }}
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 0.64.0
|
||||
Licensed Work: Outline 0.63.0
|
||||
The Licensed Work is (c) 2020 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2026-05-23
|
||||
Change Date: 2026-04-15
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
"value": "true",
|
||||
"required": true
|
||||
},
|
||||
"ALLOWED_DOMAINS": {
|
||||
"description": "Comma separated list of domains to be allowed (optional). If not set, all domains are allowed by default when using Google OAuth to signin. Consider putting {your app name}.herokuapp.com and any domain you are binding on in this list.",
|
||||
"required": false
|
||||
},
|
||||
"URL": {
|
||||
"description": "https://{your app name}.herokuapp.com, or the domain you are binding to",
|
||||
"required": true
|
||||
@@ -102,11 +106,11 @@
|
||||
"value": "openid profile email",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_CLIENT_ID": {
|
||||
"SLACK_KEY": {
|
||||
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
|
||||
"required": false
|
||||
},
|
||||
"SLACK_CLIENT_SECRET": {
|
||||
"SLACK_SECRET": {
|
||||
"description": "Your Slack client secret - d2dc414f9953226bad0a356cXXXXYYYY",
|
||||
"required": false
|
||||
},
|
||||
@@ -205,4 +209,4 @@
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redirect } from "react-router-dom";
|
||||
import { isCustomSubdomain } from "@shared/utils/domains";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import env from "~/env";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { changeLanguage } from "~/utils/language";
|
||||
|
||||
@@ -23,11 +25,29 @@ const Authenticated = ({ children }: Props) => {
|
||||
|
||||
if (auth.authenticated) {
|
||||
const { user, team } = auth;
|
||||
const { hostname } = window.location;
|
||||
|
||||
if (!team || !user) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
// If we're authenticated but viewing a domain that doesn't match the
|
||||
// current team then kick the user to the teams correct domain.
|
||||
if (team.domain) {
|
||||
if (team.domain !== hostname) {
|
||||
window.location.href = `${team.url}${window.location.pathname}`;
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
} else if (
|
||||
env.SUBDOMAINS_ENABLED &&
|
||||
team.subdomain &&
|
||||
isCustomSubdomain(hostname) &&
|
||||
!hostname.startsWith(`${team.subdomain}.`)
|
||||
) {
|
||||
window.location.href = `${team.url}${window.location.pathname}`;
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const Container = styled.div<Props>`
|
||||
padding: ${(props) => (props.withStickyHeader ? "4px 12px" : "60px 12px")};
|
||||
|
||||
${breakpoint("tablet")`
|
||||
padding: ${(props: Props) =>
|
||||
padding: ${(props: any) =>
|
||||
props.withStickyHeader ? "4px 44px 60px" : "60px 44px"};
|
||||
`};
|
||||
`;
|
||||
|
||||
@@ -42,9 +42,8 @@ function Collaborators(props: Props) {
|
||||
filter(
|
||||
users.orderedData,
|
||||
(user) =>
|
||||
(presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)) &&
|
||||
!user.isSuspended
|
||||
presentIds.includes(user.id) ||
|
||||
document.collaboratorIds.includes(user.id)
|
||||
),
|
||||
(user) => presentIds.includes(user.id)
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as React from "react";
|
||||
import Collection from "~/models/Collection";
|
||||
import { icons } from "~/components/IconPicker";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Logger from "~/utils/logger";
|
||||
|
||||
type Props = {
|
||||
collection: Collection;
|
||||
|
||||
@@ -7,23 +7,20 @@ import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
/** Callback when the dialog is submitted */
|
||||
onSubmit: () => Promise<void> | void;
|
||||
/** Text to display on the submit button */
|
||||
onSubmit: () => void;
|
||||
children: JSX.Element;
|
||||
submitText?: string;
|
||||
/** Text to display while the form is saving */
|
||||
savingText?: string;
|
||||
/** If true, the submit button will be a dangerous red */
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
const ConfirmationDialog: React.FC<Props> = ({
|
||||
function ConfirmationDialog({
|
||||
onSubmit,
|
||||
children,
|
||||
submitText,
|
||||
savingText,
|
||||
danger,
|
||||
}) => {
|
||||
}: Props) {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const { dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
@@ -56,6 +53,6 @@ const ConfirmationDialog: React.FC<Props> = ({
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default observer(ConfirmationDialog);
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { MenuItem as BaseMenuItem } from "reakit/Menu";
|
||||
import styled, { css } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { hover } from "~/styles";
|
||||
import MenuIconWrapper from "../MenuIconWrapper";
|
||||
|
||||
type Props = {
|
||||
@@ -131,18 +132,16 @@ export const MenuAnchorCSS = css<MenuAnchorProps>`
|
||||
? "pointer-events: none;"
|
||||
: `
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
&:${hover},
|
||||
&:focus,
|
||||
&.focus-visible {
|
||||
color: ${props.theme.white};
|
||||
background: ${props.dangerous ? props.theme.danger : props.theme.primary};
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
svg {
|
||||
fill: ${props.theme.white};
|
||||
}
|
||||
}
|
||||
`};
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as React from "react";
|
||||
import { MenuSeparator } from "reakit/Menu";
|
||||
import styled from "styled-components";
|
||||
|
||||
export default function Separator(rest: React.HTMLAttributes<HTMLHRElement>) {
|
||||
export default function Separator(rest: any) {
|
||||
return (
|
||||
<MenuSeparator {...rest}>
|
||||
{(props) => <HorizontalRule {...props} />}
|
||||
|
||||
@@ -69,27 +69,29 @@ const Submenu = React.forwardRef(
|
||||
);
|
||||
|
||||
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
|
||||
return items
|
||||
.filter((item) => item.visible !== false)
|
||||
.reduce((acc, item) => {
|
||||
// trim separator if the previous item was a separator
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
acc[acc.length - 1]?.type === "separator"
|
||||
) {
|
||||
return acc;
|
||||
}
|
||||
return [...acc, item];
|
||||
}, [] as TMenuItem[])
|
||||
.filter((item, index, arr) => {
|
||||
if (
|
||||
item.type === "separator" &&
|
||||
(index === 0 || index === arr.length - 1)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
let filtered = items.filter((item) => item.visible !== false);
|
||||
|
||||
// this block literally just trims unnecessary separators
|
||||
filtered = filtered.reduce((acc, item, index) => {
|
||||
// trim separators from start / end
|
||||
if (item.type === "separator" && index === 0) {
|
||||
return acc;
|
||||
}
|
||||
if (item.type === "separator" && index === filtered.length - 1) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// trim double separators looking ahead / behind
|
||||
const prev = filtered[index - 1];
|
||||
if (prev && prev.type === "separator" && item.type === "separator") {
|
||||
return acc;
|
||||
}
|
||||
|
||||
// otherwise, continue
|
||||
return [...acc, item];
|
||||
}, []);
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function Template({ items, actions, context, ...menu }: Props) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Portal } from "react-portal";
|
||||
@@ -93,19 +92,6 @@ const ContextMenu: React.FC<Props> = ({
|
||||
t,
|
||||
]);
|
||||
|
||||
// We must manually manage scroll lock for iOS support so that the scrollable
|
||||
// element can be passed into body-scroll-lock. See:
|
||||
// https://github.com/ariakit/ariakit/issues/469
|
||||
React.useEffect(() => {
|
||||
const scrollElement = backgroundRef.current;
|
||||
if (rest.visible && scrollElement) {
|
||||
disableBodyScroll(scrollElement);
|
||||
}
|
||||
return () => {
|
||||
scrollElement && enableBodyScroll(scrollElement);
|
||||
};
|
||||
}, [rest.visible]);
|
||||
|
||||
// Perf win – don't render anything until the menu has been opened
|
||||
if (!rest.visible && !previousVisible) {
|
||||
return null;
|
||||
@@ -115,7 +101,7 @@ const ContextMenu: React.FC<Props> = ({
|
||||
// trigger and the bottom of the window
|
||||
return (
|
||||
<>
|
||||
<Menu hideOnClickOutside preventBodyScroll={false} {...rest}>
|
||||
<Menu hideOnClickOutside preventBodyScroll {...rest}>
|
||||
{(props) => {
|
||||
// kind of hacky, but this is an effective way of telling which way
|
||||
// the menu will _actually_ be placed when taking into account screen
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import * as React from "react";
|
||||
import env from "~/env";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
@@ -15,7 +14,7 @@ class CopyToClipboard extends React.PureComponent<Props> {
|
||||
const elem = React.Children.only(children);
|
||||
|
||||
copy(text, {
|
||||
debug: env.ENVIRONMENT !== "production",
|
||||
debug: process.env.NODE_ENV !== "production",
|
||||
format: "text/plain",
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import { useObserver } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
|
||||
@@ -83,4 +83,4 @@ const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(DocumentMetaWithViews);
|
||||
export default DocumentMetaWithViews;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation, TFunction } from "react-i18next";
|
||||
@@ -61,4 +60,4 @@ const Done = styled(DoneIcon)<{ $animated: boolean }>`
|
||||
transform-origin: center center;
|
||||
`;
|
||||
|
||||
export default observer(DocumentTasks);
|
||||
export default DocumentTasks;
|
||||
|
||||
@@ -2,11 +2,9 @@ import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import mergeRefs from "react-merge-refs";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { supportedImageMimeTypes } from "@shared/utils/files";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
@@ -47,13 +45,12 @@ export type Props = Optional<
|
||||
shareId?: string | undefined;
|
||||
embedsDisabled?: boolean;
|
||||
grow?: boolean;
|
||||
onHeadingsChange?: (headings: Heading[]) => void;
|
||||
onSynced?: () => Promise<void>;
|
||||
onPublish?: (event: React.MouseEvent) => any;
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor>) {
|
||||
const { id, shareId } = props;
|
||||
const { documents } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
@@ -61,7 +58,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
activeLinkEvent,
|
||||
setActiveLinkEvent,
|
||||
] = React.useState<MouseEvent | null>(null);
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
|
||||
const handleLinkActive = React.useCallback((event: MouseEvent) => {
|
||||
setActiveLinkEvent(event);
|
||||
@@ -169,7 +165,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
ref?.current?.focusAtEnd();
|
||||
ref.current?.focusAtEnd();
|
||||
}, [ref]);
|
||||
|
||||
const handleDrop = React.useCallback(
|
||||
@@ -177,7 +173,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const files = getDataTransferFiles(event);
|
||||
const view = ref?.current?.view;
|
||||
const view = ref.current?.view;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
@@ -220,43 +216,11 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[]
|
||||
);
|
||||
|
||||
// Calculate if headings have changed and trigger callback if so
|
||||
const updateHeadings = React.useCallback(() => {
|
||||
if (onHeadingsChange) {
|
||||
const headings = ref?.current?.getHeadings();
|
||||
if (
|
||||
headings &&
|
||||
headings.map((h) => h.level + h.title).join("") !==
|
||||
previousHeadings.current?.map((h) => h.level + h.title).join("")
|
||||
) {
|
||||
previousHeadings.current = headings;
|
||||
onHeadingsChange(headings);
|
||||
}
|
||||
}
|
||||
}, [ref, onHeadingsChange]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
},
|
||||
[onChange, updateHeadings]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node && !previousHeadings.current) {
|
||||
updateHeadings();
|
||||
}
|
||||
},
|
||||
[updateHeadings]
|
||||
);
|
||||
|
||||
return (
|
||||
<ErrorBoundary reloadOnChunkMissing>
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, handleRefChanged])}
|
||||
ref={ref}
|
||||
uploadFile={onUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
@@ -265,7 +229,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
onHoverLink={handleLinkActive}
|
||||
onClickLink={onClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
/>
|
||||
|
||||
@@ -9,8 +9,8 @@ import CenteredContent from "~/components/CenteredContent";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import Logger from "~/utils/logger";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
reloadOnChunkMissing?: boolean;
|
||||
@@ -59,7 +59,7 @@ class ErrorBoundary extends React.Component<Props> {
|
||||
|
||||
if (this.error) {
|
||||
const error = this.error;
|
||||
const isReported = !!env.SENTRY_DSN && isCloudHosted;
|
||||
const isReported = !!env.SENTRY_DSN && isHosted;
|
||||
const isChunkError = this.error.message.match(/chunk/);
|
||||
|
||||
if (isChunkError) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
PublishIcon,
|
||||
MoveIcon,
|
||||
CheckboxIcon,
|
||||
UnpublishIcon,
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -86,11 +85,6 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
meta = t("{{userName}} published", opts);
|
||||
break;
|
||||
|
||||
case "documents.unpublish":
|
||||
icon = <UnpublishIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} unpublished", opts);
|
||||
break;
|
||||
|
||||
case "documents.move":
|
||||
icon = <MoveIcon color="currentColor" size={16} />;
|
||||
meta = t("{{userName}} moved", opts);
|
||||
@@ -119,10 +113,7 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
|
||||
<Time
|
||||
dateTime={event.createdAt}
|
||||
tooltipDelay={500}
|
||||
format={{
|
||||
en_US: "MMM do, h:mm a",
|
||||
fr_FR: "'Le 'd MMMM 'à' H:mm",
|
||||
}}
|
||||
format="MMM do, h:mm a"
|
||||
relative={false}
|
||||
addSuffix
|
||||
onClick={handleTimeClick}
|
||||
|
||||
@@ -11,19 +11,11 @@ const Flex = styled.div<{
|
||||
align?: AlignValues;
|
||||
justify?: JustifyValues;
|
||||
shrink?: boolean;
|
||||
reverse?: boolean;
|
||||
gap?: number;
|
||||
}>`
|
||||
display: flex;
|
||||
flex: ${({ auto }) => (auto ? "1 1 auto" : "initial")};
|
||||
flex-direction: ${({ column, reverse }) =>
|
||||
reverse
|
||||
? column
|
||||
? "column-reverse"
|
||||
: "row-reverse"
|
||||
: column
|
||||
? "column"
|
||||
: "row"};
|
||||
flex-direction: ${({ column }) => (column ? "column" : "row")};
|
||||
align-items: ${({ align }) => align};
|
||||
justify-content: ${({ justify }) => justify};
|
||||
flex-shrink: ${({ shrink }) => (shrink ? 1 : "initial")};
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = RootStore & {
|
||||
membership?: CollectionGroupMembership;
|
||||
showFacepile?: boolean;
|
||||
showAvatar?: boolean;
|
||||
renderActions: (params: { openMembersModal: () => void }) => React.ReactNode;
|
||||
renderActions: (arg0: { openMembersModal: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -38,13 +38,6 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
|
||||
color: ${(props) => props.theme.placeholder};
|
||||
}
|
||||
|
||||
&:-webkit-autofill,
|
||||
&:-webkit-autofill:hover,
|
||||
&:-webkit-autofill:focus {
|
||||
-webkit-box-shadow: 0 0 0px 1000px ${(props) => props.theme.background}
|
||||
inset;
|
||||
}
|
||||
|
||||
&::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
@@ -104,7 +97,7 @@ export const LabelText = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
|
||||
export type Props = React.HTMLAttributes<HTMLInputElement> & {
|
||||
type?: "text" | "email" | "checkbox" | "search" | "textarea";
|
||||
value?: string;
|
||||
label?: string;
|
||||
@@ -115,7 +108,6 @@ export type Props = Omit<React.HTMLAttributes<HTMLInputElement>, "onChange"> & {
|
||||
margin?: string | number;
|
||||
icon?: React.ReactNode;
|
||||
name?: string;
|
||||
pattern?: string;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
autoFocus?: boolean;
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { loadPolyfills } from "~/utils/polyfills";
|
||||
|
||||
/**
|
||||
* Asyncronously load required polyfills. Should wrap the React tree.
|
||||
*/
|
||||
export const LazyPolyfill: React.FC = ({ children }) => {
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadPolyfills().then(() => {
|
||||
setIsLoaded(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default LazyPolyfill;
|
||||
@@ -2,7 +2,7 @@ import { format as formatDate, formatDistanceToNow } from "date-fns";
|
||||
import * as React from "react";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateLocale, locales } from "~/utils/i18n";
|
||||
import { dateLocale } from "~/utils/i18n";
|
||||
|
||||
let callbacks: (() => void)[] = [];
|
||||
|
||||
@@ -26,7 +26,7 @@ type Props = {
|
||||
addSuffix?: boolean;
|
||||
shorten?: boolean;
|
||||
relative?: boolean;
|
||||
format?: Partial<Record<keyof typeof locales, string>>;
|
||||
format?: string;
|
||||
};
|
||||
|
||||
const LocaleTime: React.FC<Props> = ({
|
||||
@@ -38,13 +38,7 @@ const LocaleTime: React.FC<Props> = ({
|
||||
relative,
|
||||
tooltipDelay,
|
||||
}) => {
|
||||
const userLocale: string = useUserLocale() || "";
|
||||
const dateFormatLong = {
|
||||
en_US: "MMMM do, yyyy h:mm a",
|
||||
fr_FR: "'Le 'd MMMM yyyy 'à' H:mm",
|
||||
};
|
||||
const formatLocaleLong = dateFormatLong[userLocale] ?? "MMMM do, yyyy h:mm a";
|
||||
const formatLocale = format?.[userLocale] ?? formatLocaleLong;
|
||||
const userLocale = useUserLocale();
|
||||
const [_, setMinutesMounted] = React.useState(0); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const callback = React.useRef<() => void>();
|
||||
|
||||
@@ -72,13 +66,17 @@ const LocaleTime: React.FC<Props> = ({
|
||||
.replace("minute", "min");
|
||||
}
|
||||
|
||||
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
|
||||
locale,
|
||||
});
|
||||
const tooltipContent = formatDate(
|
||||
Date.parse(dateTime),
|
||||
"MMMM do, yyyy h:mm a",
|
||||
{
|
||||
locale,
|
||||
}
|
||||
);
|
||||
const content =
|
||||
relative !== false
|
||||
? relativeContent
|
||||
: formatDate(Date.parse(dateTime), formatLocale, {
|
||||
: formatDate(Date.parse(dateTime), format || "MMMM do, yyyy h:mm a", {
|
||||
locale,
|
||||
});
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ const Modal: React.FC<Props> = ({
|
||||
<Backdrop $isCentered={isCentered} {...props}>
|
||||
<Dialog
|
||||
{...dialog}
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
preventBodyScroll
|
||||
hideOnEsc
|
||||
hideOnClickOutside={!!isCentered}
|
||||
@@ -76,12 +75,7 @@ const Modal: React.FC<Props> = ({
|
||||
{(props) =>
|
||||
isCentered && !isMobile ? (
|
||||
<Small {...props}>
|
||||
<Centered
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
column
|
||||
reverse
|
||||
>
|
||||
<SmallContent shadow>{children}</SmallContent>
|
||||
<Centered onClick={(ev) => ev.stopPropagation()} column>
|
||||
<Header>
|
||||
{title && (
|
||||
<Text as="span" size="large">
|
||||
@@ -94,6 +88,7 @@ const Modal: React.FC<Props> = ({
|
||||
</NudeButton>
|
||||
</Text>
|
||||
</Header>
|
||||
<SmallContent shadow>{children}</SmallContent>
|
||||
</Centered>
|
||||
</Small>
|
||||
) : (
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { match, NavLink, Route } from "react-router-dom";
|
||||
import { NavLink, Route } from "react-router-dom";
|
||||
|
||||
type Props = React.ComponentProps<typeof NavLink> & {
|
||||
children?: (
|
||||
match:
|
||||
| match<{
|
||||
[x: string]: string | undefined;
|
||||
}>
|
||||
| boolean
|
||||
| null
|
||||
) => React.ReactNode;
|
||||
children?: (match: any) => React.ReactNode;
|
||||
exact?: boolean;
|
||||
activeStyle?: React.CSSProperties;
|
||||
to: string;
|
||||
|
||||
@@ -119,7 +119,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
// of lazy rendering then show another page.
|
||||
const leftToRender = (this.props.items?.length ?? 0) - this.renderCount;
|
||||
|
||||
if (leftToRender > 0) {
|
||||
if (leftToRender > 1) {
|
||||
this.renderCount += DEFAULT_PAGINATION_LIMIT;
|
||||
}
|
||||
|
||||
@@ -140,6 +140,7 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
renderHeading,
|
||||
onEscape,
|
||||
} = this.props;
|
||||
let previousHeading = "";
|
||||
|
||||
const showLoading =
|
||||
this.isFetching &&
|
||||
@@ -167,9 +168,8 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
aria-label={this.props["aria-label"]}
|
||||
onEscape={onEscape}
|
||||
>
|
||||
{(composite: CompositeStateReturn) => {
|
||||
let previousHeading = "";
|
||||
return items.slice(0, this.renderCount).map((item, index) => {
|
||||
{(composite: CompositeStateReturn) =>
|
||||
items.slice(0, this.renderCount).map((item, index) => {
|
||||
const children = this.props.renderItem(item, index, composite);
|
||||
|
||||
// If there is no renderHeading method passed then no date
|
||||
@@ -202,8 +202,8 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
|
||||
}
|
||||
|
||||
return children;
|
||||
});
|
||||
}}
|
||||
})
|
||||
}
|
||||
</ArrowKeyNavigation>
|
||||
{this.allowLoadMore && (
|
||||
<Waypoint key={this.renderCount} onEnter={this.loadMoreResults} />
|
||||
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
collection: Collection | null | undefined;
|
||||
onSuccess?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
ref?: (element: React.ElementRef<"div"> | null | undefined) => void;
|
||||
ref?: (arg0: React.ElementRef<"div"> | null | undefined) => void;
|
||||
};
|
||||
|
||||
@observer
|
||||
|
||||
@@ -8,7 +8,7 @@ import styled from "styled-components";
|
||||
import Flex from "~/components/Flex";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./components/Header";
|
||||
import Section from "./components/Section";
|
||||
@@ -51,7 +51,7 @@ function SettingsSidebar() {
|
||||
</Header>
|
||||
</Section>
|
||||
))}
|
||||
{!isCloudHosted && (
|
||||
{!isHosted && (
|
||||
<Section>
|
||||
<Header title={t("Installation")} />
|
||||
<Version />
|
||||
|
||||
@@ -65,7 +65,8 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
setResizing(false);
|
||||
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
if (document.activeElement) {
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'blur' does not exist on type 'Element'.
|
||||
document.activeElement.blur();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import CollectionMenu from "~/menus/CollectionMenu";
|
||||
import { NavigationNode } from "~/types";
|
||||
import DropToImport from "./DropToImport";
|
||||
@@ -48,6 +49,7 @@ const CollectionLink: React.FC<Props> = ({
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const inStarredSection = useStarredContext();
|
||||
const { showToast } = useToasts();
|
||||
|
||||
const handleTitleChange = React.useCallback(
|
||||
async (name: string) => {
|
||||
@@ -62,16 +64,17 @@ const CollectionLink: React.FC<Props> = ({
|
||||
// Drop to re-parent document
|
||||
const [{ isOver, canDrop }, drop] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject, monitor) => {
|
||||
drop: async (item: DragObject, monitor) => {
|
||||
const { id, collectionId } = item;
|
||||
const document = documents.get(id);
|
||||
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
if (!collection) {
|
||||
if (!collection || !document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = documents.get(id);
|
||||
if (collection.id === collectionId && !document?.parentDocumentId) {
|
||||
return;
|
||||
}
|
||||
@@ -97,7 +100,21 @@ const CollectionLink: React.FC<Props> = ({
|
||||
),
|
||||
});
|
||||
} else {
|
||||
documents.move(id, collection.id);
|
||||
const undo = document.metaData;
|
||||
await document.move(collection.id);
|
||||
showToast(t("Document moved"), {
|
||||
type: "info",
|
||||
action: {
|
||||
text: "undo",
|
||||
onClick: async () => {
|
||||
await document.move(
|
||||
undo.collectionId,
|
||||
undo.parentDocumentId,
|
||||
undo.index
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
canDrop: () => canUpdate,
|
||||
|
||||
@@ -37,7 +37,7 @@ function CollectionLinkChildren({
|
||||
const [{ isOverReorder, isDraggingAnyDocument }, dropToReorder] = useDrop({
|
||||
accept: "document",
|
||||
drop: (item: DragObject) => {
|
||||
if (!manualSort && item.collectionId === collection?.id) {
|
||||
if (!manualSort) {
|
||||
showToast(
|
||||
t(
|
||||
"You can't reorder documents in an alphabetically sorted collection"
|
||||
|
||||
@@ -148,7 +148,7 @@ function InnerDocumentLink(
|
||||
collectionId: collection?.id || "",
|
||||
}),
|
||||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging(),
|
||||
isDragging: !!monitor.isDragging(),
|
||||
}),
|
||||
canDrag: () => {
|
||||
return (
|
||||
@@ -178,10 +178,13 @@ function InnerDocumentLink(
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return;
|
||||
}
|
||||
documents.move(item.id, collection.id, node.id);
|
||||
|
||||
const document = documents.get(item.id);
|
||||
document?.moveWithUndo(collection.id, node.id);
|
||||
},
|
||||
canDrop: (_item, monitor) =>
|
||||
!isDraft &&
|
||||
@@ -213,7 +216,7 @@ function InnerDocumentLink(
|
||||
}
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReparent: monitor.isOver({
|
||||
isOverReparent: !!monitor.isOver({
|
||||
shallow: true,
|
||||
}),
|
||||
canDropToReparent: monitor.canDrop(),
|
||||
@@ -244,32 +247,35 @@ function InnerDocumentLink(
|
||||
return;
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
documents.move(item.id, collection.id, node.id, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
documents.move(item.id, collection.id, parentId, index + 1);
|
||||
const parentDocumentId = expanded ? node.id : parentId;
|
||||
const droppedDocumentIndex = expanded ? 0 : index + 1;
|
||||
const document = documents.get(item.id);
|
||||
document?.moveWithUndo(
|
||||
collection.id,
|
||||
parentDocumentId,
|
||||
droppedDocumentIndex
|
||||
);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverReorder: monitor.isOver(),
|
||||
isDraggingAnyDocument: monitor.canDrop(),
|
||||
isOverReorder: !!monitor.isOver(),
|
||||
isDraggingAnyDocument: !!monitor.canDrop(),
|
||||
}),
|
||||
});
|
||||
|
||||
const nodeChildren = React.useMemo(() => {
|
||||
const insertDraftDocument =
|
||||
if (
|
||||
collection &&
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.parentDocumentId === node.id;
|
||||
activeDocument?.parentDocumentId === node.id
|
||||
) {
|
||||
return sortNavigationNodes(
|
||||
[activeDocument?.asNavigationNode, ...node.children],
|
||||
collection.sort
|
||||
);
|
||||
}
|
||||
|
||||
return collection && insertDraftDocument
|
||||
? sortNavigationNodes(
|
||||
[activeDocument?.asNavigationNode, ...node.children],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: node.children;
|
||||
return node.children;
|
||||
}, [
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Badge from "~/components/Badge";
|
||||
import { version } from "../../../../package.json";
|
||||
@@ -7,7 +6,6 @@ import SidebarLink from "./SidebarLink";
|
||||
|
||||
export default function Version() {
|
||||
const [releasesBehind, setReleasesBehind] = React.useState(0);
|
||||
const { t } = useTranslation();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function loadReleases() {
|
||||
@@ -39,11 +37,10 @@ export default function Version() {
|
||||
<br />
|
||||
<LilBadge>
|
||||
{releasesBehind === 0
|
||||
? t("Up to date")
|
||||
: t(`{{ releasesBehind }} versions behind`, {
|
||||
releasesBehind,
|
||||
count: releasesBehind,
|
||||
})}
|
||||
? "Up to date"
|
||||
: `${releasesBehind} version${
|
||||
releasesBehind === 1 ? "" : "s"
|
||||
} behind`}
|
||||
</LilBadge>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -12,19 +12,19 @@ export default function useCollectionDocuments(
|
||||
return [];
|
||||
}
|
||||
|
||||
const insertDraftDocument =
|
||||
if (
|
||||
activeDocument?.isActive &&
|
||||
activeDocument?.isDraft &&
|
||||
activeDocument?.collectionId === collection.id &&
|
||||
!activeDocument?.parentDocumentId;
|
||||
!activeDocument?.parentDocumentId
|
||||
) {
|
||||
return sortNavigationNodes(
|
||||
[activeDocument.asNavigationNode, ...collection.documents],
|
||||
collection.sort
|
||||
);
|
||||
}
|
||||
|
||||
return insertDraftDocument
|
||||
? sortNavigationNodes(
|
||||
[activeDocument.asNavigationNode, ...collection.sortedDocuments],
|
||||
collection.sort,
|
||||
false
|
||||
)
|
||||
: collection.sortedDocuments;
|
||||
return collection.documents;
|
||||
}, [
|
||||
activeDocument?.isActive,
|
||||
activeDocument?.isDraft,
|
||||
@@ -32,7 +32,7 @@ export default function useCollectionDocuments(
|
||||
activeDocument?.parentDocumentId,
|
||||
activeDocument?.asNavigationNode,
|
||||
collection,
|
||||
collection?.sortedDocuments,
|
||||
collection?.documents,
|
||||
collection?.id,
|
||||
collection?.sort,
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CheckboxIcon, InfoIcon, WarningIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import { darken, lighten } from "polished";
|
||||
import * as React from "react";
|
||||
import styled, { css } from "styled-components";
|
||||
import { fadeAndScaleIn, pulse } from "~/styles/animations";
|
||||
@@ -69,17 +69,17 @@ function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
|
||||
|
||||
const Action = styled.span`
|
||||
display: inline-block;
|
||||
padding: 10px 12px;
|
||||
padding: 6px 12px;
|
||||
margin-left: 8px;
|
||||
height: 100%;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
color: ${(props) => props.theme.toastText};
|
||||
background: ${(props) => darken(0.05, props.theme.toastBackground)};
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
border-radius: 5px;
|
||||
|
||||
&:hover {
|
||||
background: ${(props) => darken(0.1, props.theme.toastBackground)};
|
||||
background: ${(props) => lighten(0.1, props.theme.toastBackground)};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const searcher = new FuzzySearch<{
|
||||
sort: true,
|
||||
});
|
||||
|
||||
class EmojiMenu extends React.PureComponent<
|
||||
class EmojiMenu extends React.Component<
|
||||
Omit<
|
||||
Props<Emoji>,
|
||||
| "renderMenuItem"
|
||||
|
||||
@@ -42,7 +42,7 @@ type Props = {
|
||||
|
||||
function isVisible(props: Props) {
|
||||
const { view } = props;
|
||||
const { selection, doc } = view.state;
|
||||
const { selection } = view.state;
|
||||
|
||||
if (isMarkActive(view.state.schema.marks.link)(view.state)) {
|
||||
return true;
|
||||
@@ -63,11 +63,6 @@ function isVisible(props: Props) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectionText = doc.cut(selection.from, selection.to).textContent;
|
||||
if (selection instanceof TextSelection && !selectionText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const slice = selection.content();
|
||||
const fragment = slice.content;
|
||||
const nodes = (fragment as any).content;
|
||||
@@ -197,6 +192,7 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
const link = isMarkActive(state.schema.marks.link)(state);
|
||||
const range = getMarkRange(selection.$from, state.schema.marks.link);
|
||||
const isImageSelection = selection.node?.type?.name === "image";
|
||||
let isTextSelection = false;
|
||||
|
||||
let items: MenuItem[] = [];
|
||||
if (isTableSelection) {
|
||||
@@ -211,6 +207,7 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
items = getDividerMenuItems(state, dictionary);
|
||||
} else {
|
||||
items = getFormattingMenuItems(state, isTemplate, dictionary);
|
||||
isTextSelection = true;
|
||||
}
|
||||
|
||||
// Some extensions may be disabled, remove corresponding items
|
||||
@@ -229,6 +226,15 @@ export default class SelectionToolbar extends React.Component<Props> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selectionText = state.doc.cut(
|
||||
state.selection.from,
|
||||
state.selection.to
|
||||
).textContent;
|
||||
|
||||
if (isTextSelection && !selectionText && !link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
view={view}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
import { lighten, transparentize } from "polished";
|
||||
import { lighten } from "polished";
|
||||
import styled from "styled-components";
|
||||
|
||||
const EditorStyles = styled.div<{
|
||||
@@ -403,9 +403,7 @@ const EditorStyles = styled.div<{
|
||||
padding: 0;
|
||||
|
||||
&.collapsed {
|
||||
svg {
|
||||
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
|
||||
}
|
||||
transform: rotate(${(props) => (props.rtl ? "90deg" : "-90deg")});
|
||||
transition-delay: 0.1s;
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -431,12 +429,10 @@ const EditorStyles = styled.div<{
|
||||
.notice-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${(props) =>
|
||||
transparentize(0.9, props.theme.noticeInfoBackground)};
|
||||
border-left: 4px solid ${(props) => props.theme.noticeInfoBackground};
|
||||
background: ${(props) => props.theme.noticeInfoBackground};
|
||||
color: ${(props) => props.theme.noticeInfoText};
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px 8px 8px;
|
||||
padding: 8px 16px;
|
||||
margin: 8px 0;
|
||||
|
||||
a {
|
||||
@@ -466,34 +462,21 @@ const EditorStyles = styled.div<{
|
||||
height: 24px;
|
||||
align-self: flex-start;
|
||||
margin-${(props) => (props.rtl ? "left" : "right")}: 4px;
|
||||
color: ${(props) => props.theme.noticeInfoBackground};
|
||||
}
|
||||
|
||||
.notice-block.tip {
|
||||
background: ${(props) =>
|
||||
transparentize(0.9, props.theme.noticeTipBackground)};
|
||||
border-left: 4px solid ${(props) => props.theme.noticeTipBackground};
|
||||
background: ${(props) => props.theme.noticeTipBackground};
|
||||
color: ${(props) => props.theme.noticeTipText};
|
||||
|
||||
.icon {
|
||||
color: ${(props) => props.theme.noticeTipBackground};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.noticeTipText};
|
||||
}
|
||||
}
|
||||
|
||||
.notice-block.warning {
|
||||
background: ${(props) =>
|
||||
transparentize(0.9, props.theme.noticeWarningBackground)};
|
||||
border-left: 4px solid ${(props) => props.theme.noticeWarningBackground};
|
||||
background: ${(props) => props.theme.noticeWarningBackground};
|
||||
color: ${(props) => props.theme.noticeWarningText};
|
||||
|
||||
.icon {
|
||||
color: ${(props) => props.theme.noticeWarningBackground};
|
||||
}
|
||||
|
||||
a {
|
||||
color: ${(props) => props.theme.noticeWarningText};
|
||||
}
|
||||
|
||||
+30
-8
@@ -18,8 +18,7 @@ import * as React from "react";
|
||||
import { DefaultTheme, ThemeProps } from "styled-components";
|
||||
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import getHeadings from "@shared/editor/lib/getHeadings";
|
||||
import getTasks from "@shared/editor/lib/getTasks";
|
||||
import headingToSlug from "@shared/editor/lib/headingToSlug";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
import Node from "@shared/editor/nodes/Node";
|
||||
@@ -29,7 +28,7 @@ import { EmbedDescriptor, EventType } from "@shared/editor/types";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Logger from "~/utils/logger";
|
||||
import BlockMenu from "./components/BlockMenu";
|
||||
import ComponentView from "./components/ComponentView";
|
||||
import EditorContext from "./components/EditorContext";
|
||||
@@ -472,7 +471,7 @@ export class Editor extends React.PureComponent<
|
||||
try {
|
||||
const element = document.querySelector(hash);
|
||||
if (element) {
|
||||
setTimeout(() => element.scrollIntoView({ behavior: "smooth" }), 0);
|
||||
element.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
} catch (err) {
|
||||
// querySelector will throw an error if the hash begins with a number
|
||||
@@ -576,11 +575,34 @@ export class Editor extends React.PureComponent<
|
||||
};
|
||||
|
||||
public getHeadings = () => {
|
||||
return getHeadings(this.view.state.doc);
|
||||
};
|
||||
const headings: { title: string; level: number; id: string }[] = [];
|
||||
const previouslySeen = {};
|
||||
|
||||
public getTasks = () => {
|
||||
return getTasks(this.view.state.doc);
|
||||
this.view.state.doc.forEach((node) => {
|
||||
if (node.type.name === "heading") {
|
||||
// calculate the optimal slug
|
||||
const slug = headingToSlug(node);
|
||||
let id = slug;
|
||||
|
||||
// check if we've already used it, and if so how many times?
|
||||
// Make the new id based on that number ensuring that we have
|
||||
// unique ID's even when headings are identical
|
||||
if (previouslySeen[slug] > 0) {
|
||||
id = headingToSlug(node, previouslySeen[slug]);
|
||||
}
|
||||
|
||||
// record that we've seen this slug for the next loop
|
||||
previouslySeen[slug] =
|
||||
previouslySeen[slug] !== undefined ? previouslySeen[slug] + 1 : 1;
|
||||
|
||||
headings.push({
|
||||
title: node.textContent,
|
||||
level: node.attrs.level,
|
||||
id,
|
||||
});
|
||||
}
|
||||
});
|
||||
return headings;
|
||||
};
|
||||
|
||||
public render() {
|
||||
|
||||
@@ -29,7 +29,7 @@ import Zapier from "~/scenes/Settings/Zapier";
|
||||
import SlackIcon from "~/components/SlackIcon";
|
||||
import ZapierIcon from "~/components/ZapierIcon";
|
||||
import env from "~/env";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import useCurrentTeam from "./useCurrentTeam";
|
||||
import usePolicy from "./usePolicy";
|
||||
|
||||
@@ -163,7 +163,7 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: "Slack",
|
||||
path: "/settings/integrations/slack",
|
||||
component: Slack,
|
||||
enabled: can.update && (!!env.SLACK_CLIENT_ID || isCloudHosted),
|
||||
enabled: can.update && (!!env.SLACK_KEY || isHosted),
|
||||
group: t("Integrations"),
|
||||
icon: SlackIcon,
|
||||
},
|
||||
@@ -171,7 +171,7 @@ const useAuthorizedSettingsConfig = () => {
|
||||
name: "Zapier",
|
||||
path: "/settings/integrations/zapier",
|
||||
component: Zapier,
|
||||
enabled: can.update && isCloudHosted,
|
||||
enabled: can.update && isHosted,
|
||||
group: t("Integrations"),
|
||||
icon: ZapierIcon,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
export default function useDebouncedCallback<T>(
|
||||
callback: (...params: T[]) => unknown,
|
||||
export default function useDebouncedCallback(
|
||||
callback: (arg0: any) => unknown,
|
||||
wait: number
|
||||
) {
|
||||
// track args & timeout handle between calls
|
||||
const argsRef = React.useRef<T[]>();
|
||||
const argsRef = React.useRef();
|
||||
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
function cleanup() {
|
||||
@@ -16,11 +16,12 @@ export default function useDebouncedCallback<T>(
|
||||
|
||||
// make sure our timeout gets cleared if consuming component gets unmounted
|
||||
React.useEffect(() => cleanup, []);
|
||||
return function (...args: T[]) {
|
||||
return function (...args: any) {
|
||||
argsRef.current = args;
|
||||
cleanup();
|
||||
timeout.current = setTimeout(() => {
|
||||
if (argsRef.current) {
|
||||
// @ts-expect-error ts-migrate(2556) FIXME: Expected 1 arguments, but got 0 or more.
|
||||
callback(...argsRef.current);
|
||||
}
|
||||
}, wait);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react";
|
||||
import { Primitive } from "utility-types";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Storage from "~/utils/Storage";
|
||||
import Logger from "~/utils/logger";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
/**
|
||||
|
||||
+13
-16
@@ -15,10 +15,9 @@ import ScrollToTop from "~/components/ScrollToTop";
|
||||
import Theme from "~/components/Theme";
|
||||
import Toasts from "~/components/Toasts";
|
||||
import env from "~/env";
|
||||
import LazyPolyfill from "./components/LazyPolyfills";
|
||||
import Routes from "./routes";
|
||||
import Logger from "./utils/Logger";
|
||||
import history from "./utils/history";
|
||||
import Logger from "./utils/logger";
|
||||
import { initSentry } from "./utils/sentry";
|
||||
|
||||
initI18n();
|
||||
@@ -76,20 +75,18 @@ if (element) {
|
||||
<Theme>
|
||||
<ErrorBoundary>
|
||||
<KBarProvider actions={[]} options={commandBarOptions}>
|
||||
<LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
</>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</LazyPolyfill>
|
||||
<LazyMotion features={loadFeatures}>
|
||||
<Router history={history}>
|
||||
<>
|
||||
<PageTheme />
|
||||
<ScrollToTop>
|
||||
<Routes />
|
||||
</ScrollToTop>
|
||||
<Toasts />
|
||||
<Dialogs />
|
||||
</>
|
||||
</Router>
|
||||
</LazyMotion>
|
||||
</KBarProvider>
|
||||
</ErrorBoundary>
|
||||
</Theme>
|
||||
|
||||
@@ -18,13 +18,14 @@ import { useMenuState, MenuButton, MenuButtonHTMLProps } from "reakit/Menu";
|
||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||
import getDataTransferFiles from "@shared/utils/getDataTransferFiles";
|
||||
import Collection from "~/models/Collection";
|
||||
import CollectionDelete from "~/scenes/CollectionDelete";
|
||||
import CollectionEdit from "~/scenes/CollectionEdit";
|
||||
import CollectionExport from "~/scenes/CollectionExport";
|
||||
import CollectionPermissions from "~/scenes/CollectionPermissions";
|
||||
import CollectionDeleteDialog from "~/components/CollectionDeleteDialog";
|
||||
import ContextMenu, { Placement } from "~/components/ContextMenu";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Template from "~/components/ContextMenu/Template";
|
||||
import Modal from "~/components/Modal";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
@@ -53,43 +54,27 @@ function CollectionMenu({
|
||||
modal,
|
||||
placement,
|
||||
});
|
||||
const [renderModals, setRenderModals] = React.useState(false);
|
||||
const team = useCurrentTeam();
|
||||
const { documents, dialogs } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const file = React.useRef<HTMLInputElement>(null);
|
||||
const [
|
||||
showCollectionPermissions,
|
||||
setShowCollectionPermissions,
|
||||
] = React.useState(false);
|
||||
const [showCollectionEdit, setShowCollectionEdit] = React.useState(false);
|
||||
const [showCollectionExport, setShowCollectionExport] = React.useState(false);
|
||||
|
||||
const handlePermissions = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Collection permissions"),
|
||||
content: <CollectionPermissions collection={collection} />,
|
||||
});
|
||||
}, [collection, dialogs, t]);
|
||||
const handleOpen = React.useCallback(() => {
|
||||
setRenderModals(true);
|
||||
|
||||
const handleEdit = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Edit collection"),
|
||||
content: (
|
||||
<CollectionEdit
|
||||
collectionId={collection.id}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [collection.id, dialogs, t]);
|
||||
|
||||
const handleExport = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Export collection"),
|
||||
content: (
|
||||
<CollectionExport
|
||||
collection={collection}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [collection, dialogs, t]);
|
||||
if (onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}, [onOpen]);
|
||||
|
||||
const handleNewDocument = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
@@ -160,7 +145,7 @@ function CollectionMenu({
|
||||
isCentered: true,
|
||||
title: t("Delete collection"),
|
||||
content: (
|
||||
<CollectionDeleteDialog
|
||||
<CollectionDelete
|
||||
collection={collection}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
@@ -253,21 +238,21 @@ function CollectionMenu({
|
||||
type: "button",
|
||||
title: `${t("Edit")}…`,
|
||||
visible: can.update,
|
||||
onClick: handleEdit,
|
||||
onClick: () => setShowCollectionEdit(true),
|
||||
icon: <EditIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Permissions")}…`,
|
||||
visible: can.update,
|
||||
onClick: handlePermissions,
|
||||
onClick: () => setShowCollectionPermissions(true),
|
||||
icon: <PadlockIcon />,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
title: `${t("Export")}…`,
|
||||
visible: !!(collection && canUserInTeam.export),
|
||||
onClick: handleExport,
|
||||
onClick: () => setShowCollectionExport(true),
|
||||
icon: <ExportIcon />,
|
||||
},
|
||||
{
|
||||
@@ -284,22 +269,19 @@ function CollectionMenu({
|
||||
],
|
||||
[
|
||||
t,
|
||||
handleUnstar,
|
||||
collection,
|
||||
can.unstar,
|
||||
can.star,
|
||||
can.update,
|
||||
can.delete,
|
||||
can.star,
|
||||
can.unstar,
|
||||
handleStar,
|
||||
handleUnstar,
|
||||
alphabeticalSort,
|
||||
handleChangeSort,
|
||||
handleNewDocument,
|
||||
handleImportDocument,
|
||||
alphabeticalSort,
|
||||
handleEdit,
|
||||
handlePermissions,
|
||||
canUserInTeam.export,
|
||||
handleExport,
|
||||
handleDelete,
|
||||
handleChangeSort,
|
||||
collection,
|
||||
canUserInTeam.export,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -329,12 +311,43 @@ function CollectionMenu({
|
||||
)}
|
||||
<ContextMenu
|
||||
{...menu}
|
||||
onOpen={onOpen}
|
||||
onOpen={handleOpen}
|
||||
onClose={onClose}
|
||||
aria-label={t("Collection")}
|
||||
>
|
||||
<Template {...menu} items={items} />
|
||||
</ContextMenu>
|
||||
{renderModals && (
|
||||
<>
|
||||
<Modal
|
||||
title={t("Collection permissions")}
|
||||
onRequestClose={() => setShowCollectionPermissions(false)}
|
||||
isOpen={showCollectionPermissions}
|
||||
>
|
||||
<CollectionPermissions collection={collection} />
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Edit collection")}
|
||||
isOpen={showCollectionEdit}
|
||||
onRequestClose={() => setShowCollectionEdit(false)}
|
||||
>
|
||||
<CollectionEdit
|
||||
onSubmit={() => setShowCollectionEdit(false)}
|
||||
collectionId={collection.id}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={t("Export collection")}
|
||||
isOpen={showCollectionExport}
|
||||
onRequestClose={() => setShowCollectionExport(false)}
|
||||
>
|
||||
<CollectionExport
|
||||
onSubmit={() => setShowCollectionExport(false)}
|
||||
collection={collection}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -108,7 +108,9 @@ function UserMenu({ user }: Props) {
|
||||
const handleRevoke = React.useCallback(
|
||||
(ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
users.delete(user);
|
||||
users.delete(user, {
|
||||
confirmation: true,
|
||||
});
|
||||
},
|
||||
[users, user]
|
||||
);
|
||||
|
||||
@@ -20,10 +20,7 @@ export default abstract class BaseModel {
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
save = async (
|
||||
params?: Record<string, any>,
|
||||
options?: Record<string, string | boolean | number | undefined>
|
||||
) => {
|
||||
save = async (params?: Record<string, any>) => {
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
@@ -32,7 +29,7 @@ export default abstract class BaseModel {
|
||||
params = this.toAPI();
|
||||
}
|
||||
|
||||
const model = await this.store.save({ ...params, id: this.id }, options);
|
||||
const model = await this.store.save({ ...params, id: this.id });
|
||||
|
||||
// if saving is successful set the new values on the model itself
|
||||
set(this, { ...params, ...model });
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { trim } from "lodash";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { sortNavigationNodes } from "@shared/utils/collections";
|
||||
import CollectionsStore from "~/stores/CollectionsStore";
|
||||
import Document from "~/models/Document";
|
||||
import ParanoidModel from "~/models/ParanoidModel";
|
||||
@@ -96,11 +95,6 @@ export default class Collection extends ParanoidModel {
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
get sortedDocuments() {
|
||||
return sortNavigationNodes(this.documents, this.sort);
|
||||
}
|
||||
|
||||
@action
|
||||
updateDocument(document: Document) {
|
||||
const travelNodes = (nodes: NavigationNode[]) =>
|
||||
@@ -136,12 +130,35 @@ export default class Collection extends ParanoidModel {
|
||||
};
|
||||
|
||||
if (this.documents) {
|
||||
travelNodes(this.sortedDocuments);
|
||||
travelNodes(this.documents);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
documentIndexInCollection(documentId: string) {
|
||||
let index: number | undefined;
|
||||
const findIndex = (nodes: NavigationNode[]) => {
|
||||
if (index) {
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach((node, i) => {
|
||||
if (node.id === documentId) {
|
||||
index = i;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
nodes.forEach((node) => {
|
||||
findIndex(node.children);
|
||||
});
|
||||
};
|
||||
|
||||
findIndex(this.documents);
|
||||
return index;
|
||||
}
|
||||
|
||||
pathToDocument(documentId: string) {
|
||||
let path: NavigationNode[] | undefined;
|
||||
const document = this.store.rootStore.documents.get(documentId);
|
||||
|
||||
+89
-34
@@ -1,11 +1,11 @@
|
||||
import { addDays, differenceInDays } from "date-fns";
|
||||
import { floor } from "lodash";
|
||||
import { action, autorun, computed, observable, set } from "mobx";
|
||||
import { action, autorun, computed, observable } from "mobx";
|
||||
import parseTitle from "@shared/utils/parseTitle";
|
||||
import unescape from "@shared/utils/unescape";
|
||||
import DocumentsStore from "~/stores/DocumentsStore";
|
||||
import User from "~/models/User";
|
||||
import type { NavigationNode } from "~/types";
|
||||
import { NavigationNode } from "~/types";
|
||||
import Storage from "~/utils/Storage";
|
||||
import ParanoidModel from "./ParanoidModel";
|
||||
import View from "./View";
|
||||
@@ -63,6 +63,7 @@ export default class Document extends ParanoidModel {
|
||||
@observable
|
||||
title: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
template: boolean;
|
||||
|
||||
@@ -218,13 +219,6 @@ export default class Document extends ParanoidModel {
|
||||
return floor((this.tasks.completed / this.tasks.total) * 100);
|
||||
}
|
||||
|
||||
@action
|
||||
updateTasks(total: number, completed: number) {
|
||||
if (total !== this.tasks.total || completed !== this.tasks.completed) {
|
||||
this.tasks = { total, completed };
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
share = async () => {
|
||||
return this.store.rootStore.shares.create({
|
||||
@@ -291,8 +285,6 @@ export default class Document extends ParanoidModel {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastViewedAt = new Date().toString();
|
||||
|
||||
return this.store.rootStore.views.create({
|
||||
documentId: this.id,
|
||||
});
|
||||
@@ -309,41 +301,104 @@ export default class Document extends ParanoidModel {
|
||||
};
|
||||
|
||||
@action
|
||||
save = async (options?: SaveOptions | undefined) => {
|
||||
const params = this.toAPI();
|
||||
const collaborativeEditing = this.store.rootStore.auth.team
|
||||
?.collaborativeEditing;
|
||||
|
||||
if (collaborativeEditing) {
|
||||
delete params.text;
|
||||
update = async (
|
||||
options: SaveOptions & {
|
||||
title?: string;
|
||||
lastRevision?: number;
|
||||
}
|
||||
) => {
|
||||
if (this.isSaving) {
|
||||
return this;
|
||||
}
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
const model = await this.store.save(
|
||||
{ ...params, id: this.id },
|
||||
{
|
||||
lastRevision: options?.lastRevision || this.revision,
|
||||
...options,
|
||||
}
|
||||
);
|
||||
if (options.lastRevision) {
|
||||
return await this.store.update(
|
||||
{
|
||||
id: this.id,
|
||||
title: options.title || this.title,
|
||||
fullWidth: this.fullWidth,
|
||||
},
|
||||
{
|
||||
lastRevision: options.lastRevision,
|
||||
publish: options?.publish,
|
||||
done: options?.done,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// if saving is successful set the new values on the model itself
|
||||
set(this, { ...params, ...model });
|
||||
|
||||
this.persistedAttributes = this.toAPI();
|
||||
|
||||
return model;
|
||||
throw new Error("Attempting to update without a lastRevision");
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
move = (collectionId: string, parentDocumentId?: string | undefined) => {
|
||||
return this.store.move(this.id, collectionId, parentDocumentId);
|
||||
@action
|
||||
save = async (options?: SaveOptions | undefined) => {
|
||||
if (this.isSaving) {
|
||||
return this;
|
||||
}
|
||||
const isCreating = !this.id;
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
if (isCreating) {
|
||||
return await this.store.create(
|
||||
{
|
||||
parentDocumentId: this.parentDocumentId,
|
||||
collectionId: this.collectionId,
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
},
|
||||
{
|
||||
publish: options?.publish,
|
||||
done: options?.done,
|
||||
autosave: options?.autosave,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return await this.store.update(
|
||||
{
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
text: this.text,
|
||||
fullWidth: this.fullWidth,
|
||||
templateId: this.templateId,
|
||||
},
|
||||
{
|
||||
lastRevision: options?.lastRevision || this.revision,
|
||||
publish: options?.publish,
|
||||
done: options?.done,
|
||||
autosave: options?.autosave,
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
};
|
||||
|
||||
move = (
|
||||
collectionId: string,
|
||||
parentDocumentId?: string | null,
|
||||
index?: number | null
|
||||
) => {
|
||||
return this.store.move(this.id, collectionId, parentDocumentId, index);
|
||||
};
|
||||
|
||||
@computed
|
||||
get metaData() {
|
||||
const collection = this.store.rootStore.collections.get(this.collectionId);
|
||||
const undo = {
|
||||
id: this.id,
|
||||
collectionId: this.collectionId,
|
||||
parentDocumentId: this.parentDocumentId,
|
||||
index: collection?.documentIndexInCollection?.(this.id),
|
||||
};
|
||||
return undo;
|
||||
}
|
||||
|
||||
duplicate = () => {
|
||||
return this.store.duplicate(this);
|
||||
};
|
||||
|
||||
@@ -55,10 +55,6 @@ class Team extends BaseModel {
|
||||
|
||||
url: string;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
allowedDomains: string[] | null | undefined;
|
||||
|
||||
@computed
|
||||
get signinMethods(): string {
|
||||
return "SSO";
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect, RouteComponentProps } from "react-router-dom";
|
||||
import Archive from "~/scenes/Archive";
|
||||
import Collection from "~/scenes/Collection";
|
||||
import DocumentNew from "~/scenes/DocumentNew";
|
||||
import Drafts from "~/scenes/Drafts";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import Search from "~/scenes/Search";
|
||||
import Templates from "~/scenes/Templates";
|
||||
import Trash from "~/scenes/Trash";
|
||||
import Layout from "~/components/AuthenticatedLayout";
|
||||
@@ -27,13 +29,6 @@ const Document = React.lazy(
|
||||
"~/scenes/Document"
|
||||
)
|
||||
);
|
||||
const Collection = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "collection" */
|
||||
"~/scenes/Collection"
|
||||
)
|
||||
);
|
||||
const Home = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
@@ -41,13 +36,8 @@ const Home = React.lazy(
|
||||
"~/scenes/Home"
|
||||
)
|
||||
);
|
||||
const Search = React.lazy(
|
||||
() =>
|
||||
import(
|
||||
/* webpackChunkName: "search" */
|
||||
"~/scenes/Search"
|
||||
)
|
||||
);
|
||||
|
||||
const NotFound = () => <Search notFound />;
|
||||
|
||||
const RedirectDocument = ({
|
||||
match,
|
||||
@@ -96,7 +86,7 @@ export default function AuthenticatedRoutes() {
|
||||
<Route exact path="/search/:term" component={Search} />
|
||||
<Route path="/404" component={Error404} />
|
||||
<SettingsRoutes />
|
||||
<Route component={Error404} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</Layout>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Switch, Redirect } from "react-router-dom";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
|
||||
|
||||
@@ -21,7 +20,6 @@ export default function SettingsRoutes() {
|
||||
<Redirect from="/settings/import-export" to="/settings/export" />
|
||||
<Redirect from="/settings/people" to="/settings/members" />
|
||||
<Redirect from="/settings/profile" to="/settings" />
|
||||
<Route component={Error404} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ function CollectionScene() {
|
||||
collectionId: collection.id,
|
||||
parentDocumentId: null,
|
||||
sort: collection.sort.field,
|
||||
direction: collection.sort.direction,
|
||||
direction: "ASC",
|
||||
}}
|
||||
showParentDocuments
|
||||
/>
|
||||
|
||||
@@ -3,10 +3,12 @@ import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import Collection from "~/models/Collection";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
@@ -14,29 +16,39 @@ type Props = {
|
||||
onSubmit: () => void;
|
||||
};
|
||||
|
||||
function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
function CollectionDelete({ collection, onSubmit }: Props) {
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const team = useCurrentTeam();
|
||||
const { showToast } = useToasts();
|
||||
const { ui } = useStores();
|
||||
const history = useHistory();
|
||||
const { t } = useTranslation();
|
||||
const handleSubmit = React.useCallback(
|
||||
async (ev: React.SyntheticEvent) => {
|
||||
ev.preventDefault();
|
||||
setIsDeleting(true);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const redirect = collection.id === ui.activeCollectionId;
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
if (redirect) {
|
||||
history.push(homePath());
|
||||
}
|
||||
};
|
||||
try {
|
||||
const redirect = collection.id === ui.activeCollectionId;
|
||||
await collection.delete();
|
||||
onSubmit();
|
||||
if (redirect) {
|
||||
history.push(homePath());
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
[collection, history, onSubmit, showToast, ui.activeCollectionId]
|
||||
);
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<>
|
||||
<Flex column>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Text type="secondary">
|
||||
<Trans
|
||||
defaults="Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash."
|
||||
@@ -61,9 +73,12 @@ function CollectionDeleteDialog({ collection, onSubmit }: Props) {
|
||||
/>
|
||||
</Text>
|
||||
) : null}
|
||||
</>
|
||||
</ConfirmationDialog>
|
||||
<Button type="submit" disabled={isDeleting} autoFocus danger>
|
||||
{isDeleting ? `${t("Deleting")}…` : t("I’m sure – Delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CollectionDeleteDialog);
|
||||
export default observer(CollectionDelete);
|
||||
@@ -104,7 +104,7 @@ const CollectionEdit = ({ collectionId, onSubmit }: Props) => {
|
||||
label={t("Sort in sidebar")}
|
||||
options={[
|
||||
{
|
||||
label: t("Alphabetical sort"),
|
||||
label: t("Alphabetical"),
|
||||
value: "title.asc",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Location } from "history";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { RouteComponentProps, useLocation } from "react-router-dom";
|
||||
import { RouteComponentProps } from "react-router-dom";
|
||||
import { useTheme } from "styled-components";
|
||||
import DocumentModel from "~/models/Document";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
@@ -29,14 +28,6 @@ type Props = RouteComponentProps<{
|
||||
location: Location<{ title?: string }>;
|
||||
};
|
||||
|
||||
// Parse the canonical origin from the SSR HTML, only needs to be done once.
|
||||
const canonicalUrl = document
|
||||
.querySelector("link[rel=canonical]")
|
||||
?.getAttribute("href");
|
||||
const canonicalOrigin = canonicalUrl
|
||||
? new URL(canonicalUrl).origin
|
||||
: window.location.origin;
|
||||
|
||||
/**
|
||||
* Find the document UUID from the slug given the sharedTree
|
||||
*
|
||||
@@ -72,7 +63,6 @@ function useDocumentId(documentSlug: string, response?: Response) {
|
||||
function SharedDocumentScene(props: Props) {
|
||||
const { ui } = useStores();
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
const [response, setResponse] = React.useState<Response>();
|
||||
const [error, setError] = React.useState<Error | null | undefined>();
|
||||
const { documents } = useStores();
|
||||
@@ -117,23 +107,15 @@ function SharedDocumentScene(props: Props) {
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<link
|
||||
rel="canonical"
|
||||
href={canonicalOrigin + location.pathname.replace(/\/$/, "")}
|
||||
/>
|
||||
</Helmet>
|
||||
<Layout title={response.document.title} sidebar={sidebar}>
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
readOnly
|
||||
/>
|
||||
</Layout>
|
||||
</>
|
||||
<Layout title={response.document.title} sidebar={sidebar}>
|
||||
<Document
|
||||
abilities={EMPTY_OBJECT}
|
||||
document={response.document}
|
||||
sharedTree={response.sharedTree}
|
||||
shareId={shareId}
|
||||
readOnly
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import Text from "~/components/Text";
|
||||
@@ -49,12 +48,11 @@ export default function Contents({ headings, isFullWidth }: Props) {
|
||||
Infinity
|
||||
);
|
||||
const headingAdjustment = minHeading - 1;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Wrapper isFullWidth={isFullWidth}>
|
||||
<Sticky>
|
||||
<Heading>{t("Contents")}</Heading>
|
||||
<Heading>Contents</Heading>
|
||||
{headings.length ? (
|
||||
<List>
|
||||
{headings.map((heading) => (
|
||||
@@ -68,9 +66,7 @@ export default function Contents({ headings, isFullWidth }: Props) {
|
||||
))}
|
||||
</List>
|
||||
) : (
|
||||
<Empty>
|
||||
{t("Headings you add to the document will appear here")}
|
||||
</Empty>
|
||||
<Empty>Headings you add to the document will appear here</Empty>
|
||||
)}
|
||||
</Sticky>
|
||||
</Wrapper>
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
} from "react-router";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import getTasks from "@shared/utils/getTasks";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Document from "~/models/Document";
|
||||
@@ -31,9 +29,9 @@ import PageTitle from "~/components/PageTitle";
|
||||
import PlaceholderDocument from "~/components/PlaceholderDocument";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import withStores from "~/components/withStores";
|
||||
import type { Editor as TEditor } from "~/editor";
|
||||
import { NavigationNode } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { isCustomDomain } from "~/utils/domains";
|
||||
import { emojiToUrl } from "~/utils/emoji";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import {
|
||||
@@ -75,7 +73,7 @@ type Props = WithTranslation &
|
||||
@observer
|
||||
class DocumentScene extends React.Component<Props> {
|
||||
@observable
|
||||
editor = React.createRef<TEditor>();
|
||||
editor = React.createRef<typeof Editor>();
|
||||
|
||||
@observable
|
||||
isUploading = false;
|
||||
@@ -98,9 +96,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
@observable
|
||||
title: string = this.props.document.title;
|
||||
|
||||
@observable
|
||||
headings: Heading[] = [];
|
||||
|
||||
getEditorText: () => string = () => this.props.document.text;
|
||||
|
||||
componentDidMount() {
|
||||
@@ -163,6 +158,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
return;
|
||||
}
|
||||
|
||||
// @ts-expect-error ts-migrate(2339) FIXME: Property 'view' does not exist on type 'unknown'.
|
||||
const { view, parser } = editorRef;
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
@@ -285,7 +281,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
autosave?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const { document } = this.props;
|
||||
const { document, auth } = this.props;
|
||||
// prevent saves when we are already saving
|
||||
if (document.isSaving) {
|
||||
return;
|
||||
@@ -311,10 +307,22 @@ class DocumentScene extends React.Component<Props> {
|
||||
this.isPublishing = !!options.publish;
|
||||
|
||||
try {
|
||||
const savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
let savedDocument = document;
|
||||
|
||||
if (auth.team?.collaborativeEditing) {
|
||||
// update does not send "text" field to the API, this is a workaround
|
||||
// while the multiplayer editor is toggleable. Once it's finalized
|
||||
// this can be cleaned up to single code path
|
||||
savedDocument = await document.update({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
} else {
|
||||
savedDocument = await document.save({
|
||||
...options,
|
||||
lastRevision: this.lastRevision,
|
||||
});
|
||||
}
|
||||
|
||||
this.isEditorDirty = false;
|
||||
this.lastRevision = savedDocument.revision;
|
||||
@@ -367,15 +375,13 @@ class DocumentScene extends React.Component<Props> {
|
||||
const { document, auth } = this.props;
|
||||
this.getEditorText = getEditorText;
|
||||
|
||||
// Keep derived task list in sync
|
||||
const tasks = this.editor.current?.getTasks();
|
||||
const total = tasks?.length ?? 0;
|
||||
const completed = tasks?.filter((t) => t.completed).length ?? 0;
|
||||
document.updateTasks(total, completed);
|
||||
|
||||
// If the multiplayer editor is enabled we're done here as changes are saved
|
||||
// through the persistence protocol. The rest of this method is legacy.
|
||||
// If the multiplayer editor is enabled then we still want to keep the local
|
||||
// text value in sync as it is used as a cache.
|
||||
if (auth.team?.collaborativeEditing) {
|
||||
action(() => {
|
||||
document.text = this.getEditorText();
|
||||
document.tasks = getTasks(document.text);
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,10 +399,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
onHeadingsChange = (headings: Heading[]) => {
|
||||
this.headings = headings;
|
||||
};
|
||||
|
||||
onChangeTitle = action((value: string) => {
|
||||
this.title = value;
|
||||
this.props.document.title = value;
|
||||
@@ -427,7 +429,12 @@ class DocumentScene extends React.Component<Props> {
|
||||
const embedsDisabled =
|
||||
(team && team.documentEmbeds === false) || document.embedsDisabled;
|
||||
|
||||
const hasHeadings = this.headings.length > 0;
|
||||
const headings = this.editor.current
|
||||
? // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'.
|
||||
this.editor.current.getHeadings()
|
||||
: [];
|
||||
|
||||
const hasHeadings = headings.length > 0;
|
||||
const showContents =
|
||||
ui.tocVisible &&
|
||||
((readOnly && hasHeadings) || team?.collaborativeEditing);
|
||||
@@ -449,7 +456,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
to={{
|
||||
pathname: canonicalUrl,
|
||||
state: this.props.location.state,
|
||||
hash: this.props.location.hash,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -542,7 +548,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
sharedTree={this.props.sharedTree}
|
||||
onSelectTemplate={this.replaceDocument}
|
||||
onSave={this.onSave}
|
||||
headings={this.headings}
|
||||
headings={headings}
|
||||
/>
|
||||
<MaxWidth
|
||||
archived={document.isArchived}
|
||||
@@ -557,7 +563,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
<Flex auto={!readOnly}>
|
||||
{showContents && (
|
||||
<Contents
|
||||
headings={this.headings}
|
||||
headings={headings}
|
||||
isFullWidth={document.fullWidth}
|
||||
/>
|
||||
)}
|
||||
@@ -581,7 +587,6 @@ class DocumentScene extends React.Component<Props> {
|
||||
onCreateLink={this.props.onCreateLink}
|
||||
onChangeTitle={this.onChangeTitle}
|
||||
onChange={this.onChange}
|
||||
onHeadingsChange={this.onHeadingsChange}
|
||||
onSave={this.onSave}
|
||||
onPublish={this.onPublish}
|
||||
onCancel={this.goBack}
|
||||
@@ -609,7 +614,7 @@ class DocumentScene extends React.Component<Props> {
|
||||
</Flex>
|
||||
</React.Suspense>
|
||||
</MaxWidth>
|
||||
{isShare && !parseDomain(window.location.origin).custom && (
|
||||
{isShare && !isCustomDomain() && (
|
||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||
)}
|
||||
</Container>
|
||||
|
||||
@@ -15,8 +15,8 @@ import usePageVisibility from "~/hooks/usePageVisibility";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import MultiplayerExtension from "~/multiplayer/MultiplayerExtension";
|
||||
import Logger from "~/utils/Logger";
|
||||
import { supportsPassiveListener } from "~/utils/browser";
|
||||
import Logger from "~/utils/logger";
|
||||
import { homePath } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = EditorProps & {
|
||||
@@ -139,6 +139,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
});
|
||||
|
||||
if (debug) {
|
||||
provider.on("status", (ev: ConnectionStatusEvent) =>
|
||||
Logger.debug("collaboration", "status", ev)
|
||||
);
|
||||
provider.on("message", (ev: MessageEvent) =>
|
||||
Logger.debug("collaboration", "incoming", {
|
||||
message: ev.message,
|
||||
@@ -238,8 +241,8 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
|
||||
// we must prevent the user from continuing to edit as their changes will not
|
||||
// be persisted. See: https://github.com/yjs/yjs/issues/303
|
||||
React.useEffect(() => {
|
||||
function onUnhandledError(event: ErrorEvent) {
|
||||
if (event.message.includes("URIError: URI malformed")) {
|
||||
function onUnhandledError(err: any) {
|
||||
if (err.message.includes("URIError: URI malformed")) {
|
||||
showToast(
|
||||
t(
|
||||
"Sorry, the last change could not be persisted – please reload the page"
|
||||
|
||||
@@ -18,8 +18,6 @@ import useKeyDown from "~/hooks/useKeyDown";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateLocale } from "~/utils/i18n";
|
||||
|
||||
type Props = {
|
||||
document: Document;
|
||||
@@ -111,9 +109,6 @@ function SharePopover({
|
||||
}, 250);
|
||||
}, [t, onRequestClose, showToast]);
|
||||
|
||||
const userLocale = useUserLocale();
|
||||
const locale = userLocale ? dateLocale(userLocale) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading>
|
||||
@@ -161,7 +156,6 @@ function SharePopover({
|
||||
Date.parse(share?.lastAccessedAt),
|
||||
{
|
||||
addSuffix: true,
|
||||
locale,
|
||||
}
|
||||
),
|
||||
})}
|
||||
|
||||
@@ -5,27 +5,13 @@ import { Trans, useTranslation } from "react-i18next";
|
||||
import Collection from "~/models/Collection";
|
||||
import Button from "~/components/Button";
|
||||
import Flex from "~/components/Flex";
|
||||
import { DragObject } from "~/components/Sidebar/components/SidebarLink";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { NavigationNode } from "~/types";
|
||||
|
||||
type Props = {
|
||||
item:
|
||||
| {
|
||||
active: boolean | null | undefined;
|
||||
children: Array<NavigationNode>;
|
||||
collectionId: string;
|
||||
depth: number;
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
title: string;
|
||||
};
|
||||
item: DragObject;
|
||||
collection: Collection;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
@@ -49,10 +35,8 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await documents.move(item.id, collection.id);
|
||||
showToast(t("Document moved"), {
|
||||
type: "info",
|
||||
});
|
||||
const document = documents.get(item.id);
|
||||
document?.moveWithUndo(collection.id);
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, {
|
||||
@@ -62,7 +46,7 @@ function DocumentReparent({ collection, item, onSubmit, onCancel }: Props) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[documents, item.id, collection.id, showToast, t, onSubmit]
|
||||
[documents, collection.id, showToast, item, onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -223,7 +223,7 @@ function Invite({ onSubmit }: Props) {
|
||||
required={!!invite.email}
|
||||
/>
|
||||
<InputSelectRole
|
||||
onChange={(role: Role) => handleRoleChange(role, index)}
|
||||
onChange={(role: any) => handleRoleChange(role as Role, index)}
|
||||
value={invite.role}
|
||||
labelHidden={index !== 0}
|
||||
short
|
||||
|
||||
@@ -445,4 +445,4 @@ const Label = styled.dd`
|
||||
color: ${(props) => props.theme.textSecondary};
|
||||
`;
|
||||
|
||||
export default React.memo(KeyboardShortcuts);
|
||||
export default KeyboardShortcuts;
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { EmailIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import AuthLogo from "~/components/AuthLogo";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import InputLarge from "~/components/InputLarge";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
name: string;
|
||||
authUrl: string;
|
||||
isCreate: boolean;
|
||||
onEmailSuccess: (email: string) => void;
|
||||
};
|
||||
|
||||
function AuthenticationProvider(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [showEmailSignin, setShowEmailSignin] = React.useState(false);
|
||||
const [isSubmitting, setSubmitting] = React.useState(false);
|
||||
const [email, setEmail] = React.useState("");
|
||||
const { isCreate, id, name, authUrl } = props;
|
||||
|
||||
const handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(event.target.value);
|
||||
};
|
||||
|
||||
const handleSubmitEmail = async (
|
||||
event: React.SyntheticEvent<HTMLFormElement>
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (showEmailSignin && email) {
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await client.post(event.currentTarget.action, {
|
||||
email,
|
||||
});
|
||||
|
||||
if (response.redirect) {
|
||||
window.location.href = response.redirect;
|
||||
} else {
|
||||
props.onEmailSuccess(email);
|
||||
}
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
setShowEmailSignin(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (id === "email") {
|
||||
if (isCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<Form method="POST" action="/auth/email" onSubmit={handleSubmitEmail}>
|
||||
{showEmailSignin ? (
|
||||
<>
|
||||
<InputLarge
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="me@domain.com"
|
||||
value={email}
|
||||
onChange={handleChangeEmail}
|
||||
disabled={isSubmitting}
|
||||
autoFocus
|
||||
required
|
||||
short
|
||||
/>
|
||||
<ButtonLarge type="submit" disabled={isSubmitting}>
|
||||
{t("Sign In")} →
|
||||
</ButtonLarge>
|
||||
</>
|
||||
) : (
|
||||
<ButtonLarge type="submit" icon={<EmailIcon />} fullwidth>
|
||||
{t("Continue with Email")}
|
||||
</ButtonLarge>
|
||||
)}
|
||||
</Form>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
<ButtonLarge
|
||||
onClick={() => (window.location.href = href)}
|
||||
icon={<AuthLogo providerName={id} />}
|
||||
fullwidth
|
||||
>
|
||||
{t("Continue with {{ authProviderName }}", {
|
||||
authProviderName: name,
|
||||
})}
|
||||
</ButtonLarge>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Form = styled.form`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export default AuthenticationProvider;
|
||||
@@ -9,13 +9,10 @@ export default function Notices() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{notice === "domain-required" && (
|
||||
{notice === "google-hd" && (
|
||||
<NoticeAlert>
|
||||
Unable to sign-in. Please navigate to your team's custom URL, then try
|
||||
to sign-in again.
|
||||
<hr />
|
||||
If you were invited to a team, you will find a link to it in the
|
||||
invite email.
|
||||
Sorry, Google sign in cannot be used with a personal email. Please try
|
||||
signing in with your Google Workspace account.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "maximum-teams" && (
|
||||
@@ -24,7 +21,13 @@ export default function Notices() {
|
||||
installation. Try another?
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "malformed-user-info" && (
|
||||
{notice === "hd-not-allowed" && (
|
||||
<NoticeAlert>
|
||||
Sorry, your Google apps domain is not allowed. Please try again with
|
||||
an allowed team domain.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "malformed_user_info" && (
|
||||
<NoticeAlert>
|
||||
We could not read the user info supplied by your identity provider.
|
||||
</NoticeAlert>
|
||||
@@ -41,7 +44,7 @@ export default function Notices() {
|
||||
try again in a few minutes.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{(notice === "auth-error" || notice === "state-mismatch") &&
|
||||
{notice === "auth-error" &&
|
||||
(description ? (
|
||||
<NoticeAlert>{description}</NoticeAlert>
|
||||
) : (
|
||||
@@ -76,12 +79,6 @@ export default function Notices() {
|
||||
Please request an invite from your team admin and try again.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
{notice === "domain-not-allowed" && (
|
||||
<NoticeAlert>
|
||||
Sorry, your domain is not allowed. Please try again with an allowed
|
||||
team domain.
|
||||
</NoticeAlert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { EmailIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import AuthLogo from "~/components/AuthLogo";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import InputLarge from "~/components/InputLarge";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
id: string;
|
||||
name: string;
|
||||
authUrl: string;
|
||||
isCreate: boolean;
|
||||
onEmailSuccess: (email: string) => void;
|
||||
};
|
||||
|
||||
type State = {
|
||||
showEmailSignin: boolean;
|
||||
isSubmitting: boolean;
|
||||
email: string;
|
||||
};
|
||||
|
||||
class Provider extends React.Component<Props, State> {
|
||||
state = {
|
||||
showEmailSignin: false,
|
||||
isSubmitting: false,
|
||||
email: "",
|
||||
};
|
||||
|
||||
handleChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
email: event.target.value,
|
||||
});
|
||||
};
|
||||
|
||||
handleSubmitEmail = async (event: React.SyntheticEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.state.showEmailSignin && this.state.email) {
|
||||
this.setState({
|
||||
isSubmitting: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await client.post(event.currentTarget.action, {
|
||||
email: this.state.email,
|
||||
});
|
||||
|
||||
if (response.redirect) {
|
||||
window.location.href = response.redirect;
|
||||
} else {
|
||||
this.props.onEmailSuccess(this.state.email);
|
||||
}
|
||||
} finally {
|
||||
this.setState({
|
||||
isSubmitting: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
showEmailSignin: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isCreate, id, name, authUrl, t } = this.props;
|
||||
|
||||
if (id === "email") {
|
||||
if (isCreate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper key="email">
|
||||
<Form
|
||||
method="POST"
|
||||
action="/auth/email"
|
||||
onSubmit={this.handleSubmitEmail}
|
||||
>
|
||||
{this.state.showEmailSignin ? (
|
||||
<>
|
||||
<InputLarge
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="me@domain.com"
|
||||
value={this.state.email}
|
||||
onChange={this.handleChangeEmail}
|
||||
disabled={this.state.isSubmitting}
|
||||
autoFocus
|
||||
required
|
||||
short
|
||||
/>
|
||||
<ButtonLarge type="submit" disabled={this.state.isSubmitting}>
|
||||
{t("Sign In")} →
|
||||
</ButtonLarge>
|
||||
</>
|
||||
) : (
|
||||
<ButtonLarge type="submit" icon={<EmailIcon />} fullwidth>
|
||||
{t("Continue with Email")}
|
||||
</ButtonLarge>
|
||||
)}
|
||||
</Form>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper key={id}>
|
||||
<ButtonLarge
|
||||
onClick={() => (window.location.href = authUrl)}
|
||||
icon={<AuthLogo providerName={id} />}
|
||||
fullwidth
|
||||
>
|
||||
{t("Continue with {{ authProviderName }}", {
|
||||
authProviderName: name,
|
||||
})}
|
||||
</ButtonLarge>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
margin-bottom: 1em;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const Form = styled.form`
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
`;
|
||||
|
||||
export default withTranslation()(Provider);
|
||||
+25
-31
@@ -5,14 +5,12 @@ import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useLocation, Link, Redirect } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { getCookie, setCookie } from "tiny-cookie";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import { setCookie } from "tiny-cookie";
|
||||
import { Config } from "~/stores/AuthStore";
|
||||
import ButtonLarge from "~/components/ButtonLarge";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import LoadingIndicator from "~/components/LoadingIndicator";
|
||||
import NoticeAlert from "~/components/NoticeAlert";
|
||||
import OutlineLogo from "~/components/OutlineLogo";
|
||||
import PageTitle from "~/components/PageTitle";
|
||||
@@ -21,16 +19,17 @@ import Text from "~/components/Text";
|
||||
import env from "~/env";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import { isCustomDomain } from "~/utils/domains";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import { changeLanguage, detectLanguage } from "~/utils/language";
|
||||
import AuthenticationProvider from "./AuthenticationProvider";
|
||||
import Notices from "./Notices";
|
||||
import Provider from "./Provider";
|
||||
|
||||
function Header({ config }: { config?: Config | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const isSubdomain = !!config?.hostname;
|
||||
|
||||
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
|
||||
if (!isHosted || isCustomDomain()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -78,11 +77,11 @@ function Login() {
|
||||
|
||||
React.useEffect(() => {
|
||||
const entries = Object.fromEntries(query.entries());
|
||||
const existing = getCookie("signupQueryParams");
|
||||
|
||||
// We don't want to set this cookie if we're viewing an error notice via
|
||||
// query string(notice =), if there are no query params, or it's already set
|
||||
if (Object.keys(entries).length && !query.get("notice") && !existing) {
|
||||
// We don't want to override this cookie if we're viewing an error notice
|
||||
// sent back from the server via query string (notice=), or if there are no
|
||||
// query params at all.
|
||||
if (Object.keys(entries).length && !query.get("notice")) {
|
||||
setCookie("signupQueryParams", JSON.stringify(entries));
|
||||
}
|
||||
}, [query]);
|
||||
@@ -103,11 +102,10 @@ function Login() {
|
||||
<PageTitle title={t("Login")} />
|
||||
<NoticeAlert>
|
||||
{t("Failed to load configuration.")}
|
||||
{!isCloudHosted && (
|
||||
{!isHosted && (
|
||||
<p>
|
||||
{t(
|
||||
"Check the network requests and server logs for full details of the error."
|
||||
)}
|
||||
Check the network requests and server logs for full details of
|
||||
the error.
|
||||
</p>
|
||||
)}
|
||||
</NoticeAlert>
|
||||
@@ -116,10 +114,9 @@ function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
// we're counting on the config request being fast, so just a simple loading
|
||||
// indicator here that's delayed by 250ms
|
||||
// we're counting on the config request being fast, so display nothing while waiting
|
||||
if (!config) {
|
||||
return <LoadingIndicator />;
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasMultipleProviders = config.providers.length > 1;
|
||||
@@ -155,10 +152,10 @@ function Login() {
|
||||
return (
|
||||
<Background>
|
||||
<Header config={config} />
|
||||
<Centered align="center" justify="center" gap={12} column auto>
|
||||
<Centered align="center" justify="center" column auto>
|
||||
<PageTitle title={t("Login")} />
|
||||
<Logo>
|
||||
{env.TEAM_LOGO && !isCloudHosted ? (
|
||||
{env.TEAM_LOGO && !isHosted ? (
|
||||
<TeamLogo src={env.TEAM_LOGO} />
|
||||
) : (
|
||||
<OutlineLogo size={38} fill="currentColor" />
|
||||
@@ -166,7 +163,7 @@ function Login() {
|
||||
</Logo>
|
||||
{isCreate ? (
|
||||
<>
|
||||
<StyledHeading centered>{t("Create an account")}</StyledHeading>
|
||||
<Heading centered>{t("Create an account")}</Heading>
|
||||
<GetStarted>
|
||||
{t(
|
||||
"Get started by choosing a sign-in method for your new team below…"
|
||||
@@ -174,16 +171,16 @@ function Login() {
|
||||
</GetStarted>
|
||||
</>
|
||||
) : (
|
||||
<StyledHeading centered>
|
||||
<Heading centered>
|
||||
{t("Login to {{ authProviderName }}", {
|
||||
authProviderName: config.name || "Outline",
|
||||
})}
|
||||
</StyledHeading>
|
||||
</Heading>
|
||||
)}
|
||||
<Notices />
|
||||
{defaultProvider && (
|
||||
<React.Fragment key={defaultProvider.id}>
|
||||
<AuthenticationProvider
|
||||
<Provider
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={handleEmailSuccess}
|
||||
{...defaultProvider}
|
||||
@@ -195,18 +192,18 @@ function Login() {
|
||||
authProviderName: defaultProvider.name,
|
||||
})}
|
||||
</Note>
|
||||
<Or data-text={t("Or")} />
|
||||
<Or />
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
)}
|
||||
{config.providers.map((provider) => {
|
||||
{config.providers.map((provider: any) => {
|
||||
if (defaultProvider && provider.id === defaultProvider.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthenticationProvider
|
||||
<Provider
|
||||
key={provider.id}
|
||||
isCreate={isCreate}
|
||||
onEmailSuccess={handleEmailSuccess}
|
||||
@@ -226,10 +223,6 @@ function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
const StyledHeading = styled(Heading)`
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const CheckEmailIcon = styled(EmailIcon)`
|
||||
margin-bottom: -1.5em;
|
||||
`;
|
||||
@@ -242,6 +235,7 @@ const Background = styled(Fade)`
|
||||
`;
|
||||
|
||||
const Logo = styled.div`
|
||||
margin-bottom: -1.5em;
|
||||
height: 38px;
|
||||
`;
|
||||
|
||||
@@ -285,7 +279,7 @@ const Or = styled.hr`
|
||||
width: 100%;
|
||||
|
||||
&:after {
|
||||
content: attr(data-text);
|
||||
content: "Or";
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
||||
@@ -23,7 +23,7 @@ import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
import Scene from "~/components/Scene";
|
||||
import Text from "~/components/Text";
|
||||
import withStores from "~/components/withStores";
|
||||
import Logger from "~/utils/Logger";
|
||||
import Logger from "~/utils/logger";
|
||||
import { searchPath } from "~/utils/routeHelpers";
|
||||
import { decodeURIComponentSafe } from "~/utils/urls";
|
||||
import CollectionFilter from "./components/CollectionFilter";
|
||||
|
||||
@@ -3,7 +3,6 @@ import { TeamIcon } from "outline-icons";
|
||||
import { useRef, useState } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { getBaseDomain } from "@shared/utils/domains";
|
||||
import Button from "~/components/Button";
|
||||
import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSelect";
|
||||
import Heading from "~/components/Heading";
|
||||
@@ -14,7 +13,7 @@ import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import ImageInput from "./components/ImageInput";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
@@ -135,16 +134,14 @@ function Details() {
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
visible={env.SUBDOMAINS_ENABLED && isCloudHosted}
|
||||
visible={env.SUBDOMAINS_ENABLED && isHosted}
|
||||
label={t("Subdomain")}
|
||||
name="subdomain"
|
||||
description={
|
||||
subdomain ? (
|
||||
<>
|
||||
<Trans>Your knowledge base will be accessible at</Trans>{" "}
|
||||
<strong>
|
||||
{subdomain}.{getBaseDomain()}
|
||||
</strong>
|
||||
<strong>{subdomain}.getoutline.com</strong>
|
||||
</>
|
||||
) : (
|
||||
t("Choose a subdomain to enable a login page just for your team.")
|
||||
|
||||
@@ -2,8 +2,7 @@ import { observer } from "mobx-react";
|
||||
import { BeakerIcon } from "outline-icons";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Heading from "~/components/Heading";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
@@ -11,11 +10,10 @@ import Text from "~/components/Text";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Features() {
|
||||
const { auth, dialogs } = useStores();
|
||||
const { auth } = useStores();
|
||||
const team = useCurrentTeam();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
@@ -23,35 +21,18 @@ function Features() {
|
||||
collaborativeEditing: team.collaborativeEditing,
|
||||
});
|
||||
|
||||
const handleChange = async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newData = { ...data, [ev.target.name]: ev.target.checked };
|
||||
setData(newData);
|
||||
const handleChange = React.useCallback(
|
||||
async (ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newData = { ...data, [ev.target.name]: ev.target.checked };
|
||||
setData(newData);
|
||||
|
||||
await auth.updateTeam(newData);
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCollabDisable = async () => {
|
||||
const newData = { ...data, collaborativeEditing: false };
|
||||
setData(newData);
|
||||
|
||||
await auth.updateTeam(newData);
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCollabDisableConfirm = () => {
|
||||
dialogs.openModal({
|
||||
isCentered: true,
|
||||
title: t("Are you sure you want to disable collaborative editing?"),
|
||||
content: (
|
||||
<DisableCollaborativeEditingDialog onSubmit={handleCollabDisable} />
|
||||
),
|
||||
});
|
||||
};
|
||||
await auth.updateTeam(newData);
|
||||
showToast(t("Settings saved"), {
|
||||
type: "success",
|
||||
});
|
||||
},
|
||||
[auth, data, showToast, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scene title={t("Features")} icon={<BeakerIcon color="currentColor" />}>
|
||||
@@ -73,42 +54,12 @@ function Features() {
|
||||
id="collaborativeEditing"
|
||||
name="collaborativeEditing"
|
||||
checked={data.collaborativeEditing}
|
||||
disabled={data.collaborativeEditing && isCloudHosted}
|
||||
onChange={
|
||||
data.collaborativeEditing
|
||||
? handleCollabDisableConfirm
|
||||
: handleChange
|
||||
}
|
||||
disabled={data.collaborativeEditing}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
function DisableCollaborativeEditingDialog({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={onSubmit}
|
||||
submitText={t("I’m sure – Disable")}
|
||||
danger
|
||||
>
|
||||
<>
|
||||
<Text type="secondary">
|
||||
<Trans>
|
||||
Enabling collaborative editing again in the future may cause some
|
||||
documents to revert to this point in time. It is not advised to
|
||||
disable this feature.
|
||||
</Trans>
|
||||
</Text>
|
||||
</>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(Features);
|
||||
|
||||
@@ -71,7 +71,7 @@ function Members() {
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [query, sort, filter, page, direction, users, users.counts.all]);
|
||||
}, [query, sort, filter, page, direction, users]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let filtered = users.orderedData;
|
||||
|
||||
@@ -13,7 +13,7 @@ import env from "~/env";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Notifications() {
|
||||
@@ -45,15 +45,18 @@ function Notifications() {
|
||||
),
|
||||
},
|
||||
{
|
||||
visible: isCloudHosted,
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
visible: isHosted,
|
||||
event: "emails.onboarding",
|
||||
title: t("Getting started"),
|
||||
description: t(
|
||||
"Tips on getting started with Outline’s features and functionality"
|
||||
"Tips on getting started with Outline`s features and functionality"
|
||||
),
|
||||
},
|
||||
{
|
||||
visible: isCloudHosted,
|
||||
visible: isHosted,
|
||||
event: "emails.features",
|
||||
title: t("New features"),
|
||||
description: t("Receive an email when new features of note are added"),
|
||||
@@ -118,6 +121,10 @@ function Notifications() {
|
||||
<h2>{t("Notifications")}</h2>
|
||||
|
||||
{options.map((option) => {
|
||||
if (option.separator || !option.event) {
|
||||
return <br />;
|
||||
}
|
||||
|
||||
const setting = notificationSettings.getByEvent(option.event);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,27 +1,20 @@
|
||||
import { debounce } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { CloseIcon, PadlockIcon } from "outline-icons";
|
||||
import { PadlockIcon } from "outline-icons";
|
||||
import { useState } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Button from "~/components/Button";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import Heading from "~/components/Heading";
|
||||
import Input from "~/components/Input";
|
||||
import InputSelect from "~/components/InputSelect";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import Scene from "~/components/Scene";
|
||||
import Switch from "~/components/Switch";
|
||||
import Text from "~/components/Text";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import env from "~/env";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import SettingRow from "./components/SettingRow";
|
||||
|
||||
function Security() {
|
||||
@@ -36,7 +29,6 @@ function Security() {
|
||||
defaultUserRole: team.defaultUserRole,
|
||||
memberCollectionCreate: team.memberCollectionCreate,
|
||||
inviteRequired: team.inviteRequired,
|
||||
allowedDomains: team.allowedDomains,
|
||||
});
|
||||
|
||||
const authenticationMethods = team.signinMethods;
|
||||
@@ -51,17 +43,13 @@ function Security() {
|
||||
[showToast, t]
|
||||
);
|
||||
|
||||
const [domainsChanged, setDomainsChanged] = useState(false);
|
||||
|
||||
const saveData = React.useCallback(
|
||||
async (newData) => {
|
||||
try {
|
||||
setData(newData);
|
||||
await auth.updateTeam(newData);
|
||||
showSuccessMessage();
|
||||
setDomainsChanged(false);
|
||||
} catch (err) {
|
||||
setDomainsChanged(true);
|
||||
showToast(err.message, {
|
||||
type: "error",
|
||||
});
|
||||
@@ -122,35 +110,6 @@ function Security() {
|
||||
[data, saveData, t, dialogs, authenticationMethods]
|
||||
);
|
||||
|
||||
const handleRemoveDomain = async (index: number) => {
|
||||
const newData = {
|
||||
...data,
|
||||
};
|
||||
newData.allowedDomains && newData.allowedDomains.splice(index, 1);
|
||||
|
||||
setData(newData);
|
||||
setDomainsChanged(true);
|
||||
};
|
||||
|
||||
const handleAddDomain = () => {
|
||||
const newData = {
|
||||
...data,
|
||||
allowedDomains: [...(data.allowedDomains || []), ""],
|
||||
};
|
||||
|
||||
setData(newData);
|
||||
};
|
||||
|
||||
const createOnDomainChangedHandler = (index: number) => (
|
||||
ev: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const newData = { ...data };
|
||||
|
||||
newData.allowedDomains![index] = ev.currentTarget.value;
|
||||
setData(newData);
|
||||
setDomainsChanged(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Scene title={t("Security")} icon={<PadlockIcon color="currentColor" />}>
|
||||
<Heading>{t("Security")}</Heading>
|
||||
@@ -212,7 +171,7 @@ function Security() {
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</SettingRow>
|
||||
{isCloudHosted && (
|
||||
{isHosted && (
|
||||
<SettingRow
|
||||
label={t("Allow authorized signups")}
|
||||
name="allowSignups"
|
||||
@@ -261,72 +220,8 @@ function Security() {
|
||||
short
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("Allowed Domains")}
|
||||
name="allowedDomains"
|
||||
description={t(
|
||||
"The domains which should be allowed to create accounts. This applies to both SSO and Email logins. Changing this setting does not affect existing user accounts."
|
||||
)}
|
||||
>
|
||||
{data.allowedDomains &&
|
||||
data.allowedDomains.map((domain, index) => (
|
||||
<Flex key={index} gap={4}>
|
||||
<Input
|
||||
key={index}
|
||||
id={`allowedDomains${index}`}
|
||||
value={domain}
|
||||
autoFocus={!domain}
|
||||
placeholder="example.com"
|
||||
required
|
||||
flex
|
||||
onChange={createOnDomainChangedHandler(index)}
|
||||
/>
|
||||
<Remove>
|
||||
<Tooltip tooltip={t("Remove domain")} placement="top">
|
||||
<NudeButton onClick={() => handleRemoveDomain(index)}>
|
||||
<CloseIcon />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
</Remove>
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<Flex justify="space-between" gap={4} style={{ flexWrap: "wrap" }}>
|
||||
{!data.allowedDomains?.length ||
|
||||
data.allowedDomains[data.allowedDomains.length - 1] !== "" ? (
|
||||
<Fade>
|
||||
<Button type="button" onClick={handleAddDomain} neutral>
|
||||
{data.allowedDomains?.length ? (
|
||||
<Trans>Add another</Trans>
|
||||
) : (
|
||||
<Trans>Add a domain</Trans>
|
||||
)}
|
||||
</Button>
|
||||
</Fade>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
|
||||
{domainsChanged && (
|
||||
<Fade>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleChange}
|
||||
disabled={auth.isSaving}
|
||||
>
|
||||
<Trans>Save changes</Trans>
|
||||
</Button>
|
||||
</Fade>
|
||||
)}
|
||||
</Flex>
|
||||
</SettingRow>
|
||||
</Scene>
|
||||
);
|
||||
}
|
||||
|
||||
const Remove = styled("div")`
|
||||
margin-top: 6px;
|
||||
`;
|
||||
|
||||
export default observer(Security);
|
||||
|
||||
@@ -83,7 +83,7 @@ function Slack() {
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
{env.SLACK_CLIENT_ID ? (
|
||||
{env.SLACK_KEY ? (
|
||||
<>
|
||||
<p>
|
||||
{commandIntegration ? (
|
||||
@@ -92,14 +92,7 @@ function Slack() {
|
||||
</Button>
|
||||
) : (
|
||||
<SlackButton
|
||||
scopes={[
|
||||
"commands",
|
||||
"links:read",
|
||||
"links:write",
|
||||
// TODO: Wait forever for Slack to approve these scopes.
|
||||
//"users:read",
|
||||
//"users:read.email",
|
||||
]}
|
||||
scopes={["commands", "links:read", "links:write"]}
|
||||
redirectUri={`${env.URL}/auth/slack.commands`}
|
||||
state={team.id}
|
||||
icon={<SlackIcon color="currentColor" />}
|
||||
|
||||
@@ -72,7 +72,7 @@ function DropToImport({ disabled, onSubmit, children, format }: Props) {
|
||||
<>
|
||||
{isImporting && <LoadingIndicator />}
|
||||
<Dropzone
|
||||
accept="application/zip, application/x-zip-compressed"
|
||||
accept="application/zip"
|
||||
onDropAccepted={handleFiles}
|
||||
onDropRejected={handleRejection}
|
||||
disabled={isImporting}
|
||||
|
||||
@@ -14,6 +14,8 @@ import withStores from "~/components/withStores";
|
||||
import { compressImage } from "~/utils/compressImage";
|
||||
import { uploadFile, dataUrlToBlob } from "~/utils/files";
|
||||
|
||||
const EMPTY_OBJECT = {};
|
||||
|
||||
export type Props = {
|
||||
onSuccess: (url: string) => void | Promise<void>;
|
||||
onError: (error: string) => void;
|
||||
@@ -82,7 +84,7 @@ class ImageUpload extends React.Component<RootStore & Props> {
|
||||
this.isCropping = false;
|
||||
};
|
||||
|
||||
handleZoom = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleZoom = (event: React.DragEvent<any>) => {
|
||||
const target = event.target;
|
||||
|
||||
if (target instanceof HTMLInputElement) {
|
||||
@@ -117,6 +119,7 @@ class ImageUpload extends React.Component<RootStore & Props> {
|
||||
max="2"
|
||||
step="0.01"
|
||||
defaultValue="1"
|
||||
// @ts-expect-error ts-migrate(2769) FIXME: No overload matches this call.
|
||||
onChange={this.handleZoom}
|
||||
/>
|
||||
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
|
||||
@@ -136,6 +139,9 @@ class ImageUpload extends React.Component<RootStore & Props> {
|
||||
<Dropzone
|
||||
accept="image/png, image/jpeg"
|
||||
onDropAccepted={this.onDropAccepted}
|
||||
// @ts-expect-error ts-migrate(2322) FIXME: Type '{ children: ({ getRootProps, getInputProps }... Remove this comment to see the full error message
|
||||
style={EMPTY_OBJECT}
|
||||
disablePreview
|
||||
>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
|
||||
@@ -89,4 +89,4 @@ const Badges = styled.div`
|
||||
margin-left: -10px;
|
||||
`;
|
||||
|
||||
export default observer(PeopleTable);
|
||||
export default PeopleTable;
|
||||
|
||||
@@ -41,10 +41,6 @@ const Column = styled.div`
|
||||
min-width: 60%;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
${breakpoint("tablet")`
|
||||
p {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -15,18 +15,13 @@ type Props = {
|
||||
function SlackButton({ state = "", scopes, redirectUri, label, icon }: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleClick = () => {
|
||||
if (!env.SLACK_CLIENT_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = slackAuth(
|
||||
const handleClick = () =>
|
||||
(window.location.href = slackAuth(
|
||||
state,
|
||||
scopes,
|
||||
env.SLACK_CLIENT_ID,
|
||||
env.SLACK_KEY,
|
||||
redirectUri
|
||||
);
|
||||
};
|
||||
));
|
||||
|
||||
return (
|
||||
<Button onClick={handleClick} icon={icon} neutral>
|
||||
|
||||
+4
-25
@@ -2,7 +2,6 @@ import * as Sentry from "@sentry/react";
|
||||
import invariant from "invariant";
|
||||
import { observable, action, computed, autorun, runInAction } from "mobx";
|
||||
import { getCookie, setCookie, removeCookie } from "tiny-cookie";
|
||||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Policy from "~/models/Policy";
|
||||
import Team from "~/models/Team";
|
||||
@@ -10,6 +9,7 @@ import User from "~/models/User";
|
||||
import env from "~/env";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Storage from "~/utils/Storage";
|
||||
import { getCookieDomain } from "~/utils/domains";
|
||||
|
||||
const AUTH_STORE = "AUTH_STORE";
|
||||
const NO_REDIRECT_PATHS = ["/", "/create", "/home"];
|
||||
@@ -162,23 +162,6 @@ export default class AuthStore {
|
||||
});
|
||||
}
|
||||
|
||||
// Redirect to the correct custom domain or team subdomain if needed
|
||||
// Occurs when the (sub)domain is changed in admin and the user hits an old url
|
||||
const { hostname, pathname } = window.location;
|
||||
|
||||
if (this.team.domain) {
|
||||
if (this.team.domain !== hostname) {
|
||||
window.location.href = `${team.url}${pathname}`;
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
env.SUBDOMAINS_ENABLED &&
|
||||
parseDomain(hostname).teamSubdomain !== (team.subdomain ?? "")
|
||||
) {
|
||||
window.location.href = `${team.url}${pathname}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we came from a redirect then send the user immediately there
|
||||
const postLoginRedirectPath = getCookie("postLoginRedirectPath");
|
||||
|
||||
@@ -200,7 +183,9 @@ export default class AuthStore {
|
||||
|
||||
@action
|
||||
deleteUser = async () => {
|
||||
await client.post(`/users.delete`);
|
||||
await client.post(`/users.delete`, {
|
||||
confirmation: true,
|
||||
});
|
||||
runInAction("AuthStore#updateUser", () => {
|
||||
this.user = null;
|
||||
this.team = null;
|
||||
@@ -253,12 +238,6 @@ export default class AuthStore {
|
||||
|
||||
@action
|
||||
logout = async (savePath = false) => {
|
||||
if (!this.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.post(`/auth.delete`);
|
||||
|
||||
// remove user and team from localStorage
|
||||
Storage.set(AUTH_STORE, {
|
||||
user: null,
|
||||
|
||||
@@ -106,14 +106,11 @@ export default abstract class BaseStore<T extends BaseModel> {
|
||||
this.data.delete(id);
|
||||
}
|
||||
|
||||
save(
|
||||
params: Partial<T>,
|
||||
options?: Record<string, string | boolean | number | undefined>
|
||||
): Promise<T> {
|
||||
save(params: Partial<T>): Promise<T> {
|
||||
if (params.id) {
|
||||
return this.update(params, options);
|
||||
return this.update(params);
|
||||
}
|
||||
return this.create(params, options);
|
||||
return this.create(params);
|
||||
}
|
||||
|
||||
get(id: string): T | undefined {
|
||||
|
||||
@@ -147,7 +147,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
|
||||
return compact([
|
||||
...drafts,
|
||||
...collection.sortedDocuments.map((node) => this.get(node.id)),
|
||||
...collection.documents.map((node) => this.get(node.id)),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -303,31 +303,27 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
fetchArchived = async (options?: PaginationParams): Promise<Document[]> => {
|
||||
fetchArchived = async (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("archived", options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchDeleted = async (options?: PaginationParams): Promise<Document[]> => {
|
||||
fetchDeleted = async (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("deleted", options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchRecentlyUpdated = async (
|
||||
options?: PaginationParams
|
||||
): Promise<Document[]> => {
|
||||
fetchRecentlyUpdated = async (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("list", options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchTemplates = async (options?: PaginationParams): Promise<Document[]> => {
|
||||
fetchTemplates = async (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("list", { ...options, template: true });
|
||||
};
|
||||
|
||||
@action
|
||||
fetchAlphabetical = async (
|
||||
options?: PaginationParams
|
||||
): Promise<Document[]> => {
|
||||
fetchAlphabetical = async (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("list", {
|
||||
sort: "title",
|
||||
direction: "ASC",
|
||||
@@ -338,7 +334,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
@action
|
||||
fetchLeastRecentlyUpdated = async (
|
||||
options?: PaginationParams
|
||||
): Promise<Document[]> => {
|
||||
): Promise<any> => {
|
||||
return this.fetchNamedPage("list", {
|
||||
sort: "updatedAt",
|
||||
direction: "ASC",
|
||||
@@ -347,9 +343,7 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
fetchRecentlyPublished = async (
|
||||
options?: PaginationParams
|
||||
): Promise<Document[]> => {
|
||||
fetchRecentlyPublished = async (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("list", {
|
||||
sort: "publishedAt",
|
||||
direction: "DESC",
|
||||
@@ -358,24 +352,22 @@ export default class DocumentsStore extends BaseStore<Document> {
|
||||
};
|
||||
|
||||
@action
|
||||
fetchRecentlyViewed = async (
|
||||
options?: PaginationParams
|
||||
): Promise<Document[]> => {
|
||||
fetchRecentlyViewed = async (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("viewed", options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchStarred = (options?: PaginationParams): Promise<Document[]> => {
|
||||
fetchStarred = (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("starred", options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchDrafts = (options?: PaginationParams): Promise<Document[]> => {
|
||||
fetchDrafts = (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("drafts", options);
|
||||
};
|
||||
|
||||
@action
|
||||
fetchOwned = (options?: PaginationParams): Promise<Document[]> => {
|
||||
fetchOwned = (options?: PaginationParams): Promise<any> => {
|
||||
return this.fetchNamedPage("list", options);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { trim } from "lodash";
|
||||
import queryString from "query-string";
|
||||
import EDITOR_VERSION from "@shared/editor/version";
|
||||
import stores from "~/stores";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import isHosted from "~/utils/isHosted";
|
||||
import download from "./download";
|
||||
import {
|
||||
AuthorizationError,
|
||||
@@ -107,7 +107,7 @@ class ApiClient {
|
||||
// not needed for authentication this offers a performance increase.
|
||||
// For self-hosted we include them to support a wide variety of
|
||||
// authenticated proxies, e.g. Pomerium, Cloudflare Access etc.
|
||||
credentials: isCloudHosted ? "omit" : "same-origin",
|
||||
credentials: isHosted ? "omit" : "same-origin",
|
||||
cache: "no-cache",
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { parseDomain, stripSubdomain } from "@shared/utils/domains";
|
||||
import env from "~/env";
|
||||
|
||||
export function getCookieDomain(domain: string) {
|
||||
return env.SUBDOMAINS_ENABLED ? stripSubdomain(domain) : domain;
|
||||
}
|
||||
|
||||
export function isCustomDomain() {
|
||||
const parsed = parseDomain(window.location.origin);
|
||||
const main = parseDomain(env.URL);
|
||||
return (
|
||||
parsed && main && (main.domain !== parsed.domain || main.tld !== parsed.tld)
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import invariant from "invariant";
|
||||
import { client } from "./ApiClient";
|
||||
import Logger from "./Logger";
|
||||
import Logger from "./logger";
|
||||
|
||||
type UploadOptions = {
|
||||
/** The user facing name of the file */
|
||||
|
||||
@@ -39,5 +39,3 @@ const locales = {
|
||||
export function dateLocale(userLocale: string | null | undefined) {
|
||||
return userLocale ? locales[userLocale] : undefined;
|
||||
}
|
||||
|
||||
export { locales };
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import env from "~/env";
|
||||
|
||||
/**
|
||||
* True if the current installation is the cloud hosted version at getoutline.com
|
||||
*/
|
||||
const isCloudHosted = env.DEPLOYMENT === "hosted";
|
||||
|
||||
export default isCloudHosted;
|
||||
@@ -0,0 +1,5 @@
|
||||
import env from "~/env";
|
||||
|
||||
const isHosted = env.DEPLOYMENT === "hosted";
|
||||
|
||||
export default isHosted;
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Loads required polyfills.
|
||||
*
|
||||
* @returns A promise that resolves when all required polyfills are loaded
|
||||
*/
|
||||
export async function loadPolyfills() {
|
||||
const polyfills = [];
|
||||
|
||||
if (!supportsResizeObserver()) {
|
||||
polyfills.push(
|
||||
import("@juggle/resize-observer").then((module) => {
|
||||
window.ResizeObserver = module.ResizeObserver;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(polyfills);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect ResizeObserver compatability.
|
||||
*
|
||||
* @returns true if the current browser supports ResizeObserver
|
||||
*/
|
||||
function supportsResizeObserver() {
|
||||
return (
|
||||
"ResizeObserver" in global &&
|
||||
"ResizeObserverEntry" in global &&
|
||||
"contentRect" in ResizeObserverEntry.prototype
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@ export function initSentry(history: History) {
|
||||
routingInstrumentation: Sentry.reactRouterV5Instrumentation(history),
|
||||
}),
|
||||
],
|
||||
tracesSampleRate: env.ENVIRONMENT === "production" ? 0.1 : 1,
|
||||
tracesSampleRate: process.env.NODE_ENV === "production" ? 0.1 : 1,
|
||||
ignoreErrors: [
|
||||
"ResizeObserver loop completed with undelivered notifications",
|
||||
"ResizeObserver loop limit exceeded",
|
||||
|
||||
+1
-3
@@ -3,8 +3,7 @@ services:
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379"
|
||||
user: "redis:redis"
|
||||
- "127.0.0.1:6479:6379"
|
||||
postgres:
|
||||
image: postgres
|
||||
ports:
|
||||
@@ -13,7 +12,6 @@ services:
|
||||
POSTGRES_USER: user
|
||||
POSTGRES_PASSWORD: pass
|
||||
POSTGRES_DB: outline
|
||||
user: "postgres:postgres"
|
||||
s3:
|
||||
image: lphoward/fake-s3
|
||||
ports:
|
||||
|
||||
+9
-18
@@ -38,9 +38,7 @@
|
||||
"type": "git",
|
||||
"url": "git+ssh://git@github.com/outline/outline.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"browserslist": "> 0.25%, not dead",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.16.0",
|
||||
"@babel/plugin-proposal-decorators": "^7.10.5",
|
||||
@@ -48,16 +46,14 @@
|
||||
"@babel/plugin-transform-regenerator": "^7.10.4",
|
||||
"@babel/preset-env": "^7.16.0",
|
||||
"@babel/preset-react": "^7.16.0",
|
||||
"@bull-board/api": "^3.11.1",
|
||||
"@bull-board/koa": "^3.11.1",
|
||||
"@bull-board/api": "^3.5.0",
|
||||
"@bull-board/koa": "^3.5.0",
|
||||
"@dnd-kit/core": "^4.0.3",
|
||||
"@dnd-kit/modifiers": "^4.0.0",
|
||||
"@dnd-kit/sortable": "^5.1.0",
|
||||
"@getoutline/y-prosemirror": "^1.0.18",
|
||||
"@hocuspocus/provider": "^1.0.0-alpha.36",
|
||||
"@hocuspocus/server": "^1.0.0-alpha.102",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.44",
|
||||
"@juggle/resize-observer": "^3.3.1",
|
||||
"@outlinewiki/koa-passport": "^4.1.4",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@renderlesskit/react": "^0.6.0",
|
||||
@@ -72,11 +68,9 @@
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"babel-plugin-styled-components": "^1.11.1",
|
||||
"babel-plugin-transform-class-properties": "^6.24.1",
|
||||
"body-scroll-lock": "^4.0.0-beta.0",
|
||||
"bull": "^3.29.0",
|
||||
"cancan": "3.1.0",
|
||||
"chalk": "^4.1.0",
|
||||
"class-validator": "^0.13.2",
|
||||
"compressorjs": "^1.0.7",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
"core-js": "^3.10.2",
|
||||
@@ -84,7 +78,6 @@
|
||||
"datadog-metrics": "^0.9.3",
|
||||
"date-fns": "^2.25.0",
|
||||
"dotenv": "^4.0.0",
|
||||
"email-providers": "^1.13.1",
|
||||
"emoji-regex": "^10.0.0",
|
||||
"es6-error": "^4.1.1",
|
||||
"exports-loader": "^0.6.4",
|
||||
@@ -104,6 +97,7 @@
|
||||
"invariant": "^2.2.4",
|
||||
"ioredis": "^4.28.0",
|
||||
"is-printable-key-event": "^1.0.0",
|
||||
"joplin-turndown-plugin-gfm": "^1.0.12",
|
||||
"json-loader": "0.5.4",
|
||||
"jsonwebtoken": "^8.5.0",
|
||||
"jszip": "^3.7.1",
|
||||
@@ -130,7 +124,6 @@
|
||||
"mobx": "^4.15.4",
|
||||
"mobx-react": "^6.3.1",
|
||||
"natural-sort": "^1.0.0",
|
||||
"node-htmldiff": "^0.9.3",
|
||||
"nodemailer": "^6.6.1",
|
||||
"outline-icons": "^1.42.0",
|
||||
"oy-vey": "^0.10.0",
|
||||
@@ -169,7 +162,6 @@
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-i18next": "^11.16.6",
|
||||
"react-medium-image-zoom": "^3.1.3",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-portal": "^4.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-table": "^7.7.0",
|
||||
@@ -181,10 +173,10 @@
|
||||
"refractor": "^3.5.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"semver": "^7.3.2",
|
||||
"sequelize": "^6.20.1",
|
||||
"sequelize-cli": "^6.4.1",
|
||||
"sequelize": "^6.9.0",
|
||||
"sequelize-cli": "^6.3.0",
|
||||
"sequelize-encrypted": "^1.0.0",
|
||||
"sequelize-typescript": "^2.1.3",
|
||||
"sequelize-typescript": "^2.1.1",
|
||||
"slate": "0.45.0",
|
||||
"slate-md-serializer": "5.5.4",
|
||||
"slug": "^4.0.4",
|
||||
@@ -216,7 +208,6 @@
|
||||
"@babel/preset-typescript": "^7.16.0",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
|
||||
"@relative-ci/agent": "^3.0.0",
|
||||
"@types/body-scroll-lock": "^3.1.0",
|
||||
"@types/bull": "^3.15.5",
|
||||
"@types/crypto-js": "^4.1.0",
|
||||
"@types/datadog-metrics": "^0.6.2",
|
||||
@@ -325,7 +316,7 @@
|
||||
"webpack-cli": "^3.3.12",
|
||||
"webpack-manifest-plugin": "^3.0.0",
|
||||
"webpack-pwa-manifest": "^4.3.0",
|
||||
"workbox-webpack-plugin": "^6.5.3",
|
||||
"workbox-webpack-plugin": "^6.3.0",
|
||||
"yarn-deduplicate": "^3.1.0"
|
||||
},
|
||||
"resolutions": {
|
||||
@@ -335,5 +326,5 @@
|
||||
"dot-prop": "^5.2.0",
|
||||
"js-yaml": "^3.14.1"
|
||||
},
|
||||
"version": "0.64.3"
|
||||
"version": "0.63.0"
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -4,7 +4,7 @@ import {
|
||||
onLoadDocumentPayload,
|
||||
Extension,
|
||||
} from "@hocuspocus/server";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Logger from "@server/logging/logger";
|
||||
|
||||
export default class LoggerExtension implements Extension {
|
||||
async onLoadDocument(data: onLoadDocumentPayload) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
import invariant from "invariant";
|
||||
import * as Y from "yjs";
|
||||
import { sequelize } from "@server/database/sequelize";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Logger from "@server/logging/logger";
|
||||
import { APM } from "@server/logging/tracing";
|
||||
import Document from "@server/models/Document";
|
||||
import documentCollaborativeUpdater from "../commands/documentCollaborativeUpdater";
|
||||
@@ -54,7 +54,6 @@ export default class PersistenceExtension implements Extension {
|
||||
state: Buffer.from(state),
|
||||
},
|
||||
{
|
||||
silent: true,
|
||||
hooks: false,
|
||||
transaction,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
|
||||
import env from "@server/env";
|
||||
import { TeamDomain } from "@server/models";
|
||||
import Collection from "@server/models/Collection";
|
||||
import UserAuthentication from "@server/models/UserAuthentication";
|
||||
import { buildUser, buildTeam } from "@server/test/factories";
|
||||
import { flushdb, seed } from "@server/test/support";
|
||||
import { flushdb } from "@server/test/support";
|
||||
import accountProvisioner from "./accountProvisioner";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -15,14 +13,12 @@ describe("accountProvisioner", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create a new user and team", async () => {
|
||||
env.DEPLOYMENT = "hosted";
|
||||
|
||||
const spy = jest.spyOn(WelcomeEmail, "schedule");
|
||||
const { user, team, isNewTeam, isNewUser } = await accountProvisioner({
|
||||
ip,
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email: "jenny@example-company.com",
|
||||
email: "jenny@example.com",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
username: "jtester",
|
||||
},
|
||||
@@ -33,7 +29,7 @@ describe("accountProvisioner", () => {
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId: "example-company.com",
|
||||
providerId: "example.com",
|
||||
},
|
||||
authentication: {
|
||||
providerId: "123456789",
|
||||
@@ -47,7 +43,7 @@ describe("accountProvisioner", () => {
|
||||
expect(auth.scopes.length).toEqual(1);
|
||||
expect(auth.scopes[0]).toEqual("read");
|
||||
expect(team.name).toEqual("New team");
|
||||
expect(user.email).toEqual("jenny@example-company.com");
|
||||
expect(user.email).toEqual("jenny@example.com");
|
||||
expect(user.username).toEqual("jtester");
|
||||
expect(isNewUser).toEqual(true);
|
||||
expect(isNewTeam).toEqual(true);
|
||||
@@ -68,7 +64,7 @@ describe("accountProvisioner", () => {
|
||||
});
|
||||
const authentications = await existing.$get("authentications");
|
||||
const authentication = authentications[0];
|
||||
const newEmail = "test@example-company.com";
|
||||
const newEmail = "test@example.com";
|
||||
const newUsername = "tname";
|
||||
const { user, isNewUser, isNewTeam } = await accountProvisioner({
|
||||
ip,
|
||||
@@ -152,100 +148,6 @@ describe("accountProvisioner", () => {
|
||||
expect(error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should throw an error when the domain is not allowed", async () => {
|
||||
const { admin, team: existingTeam } = await seed();
|
||||
const providers = await existingTeam.$get("authenticationProviders");
|
||||
const authenticationProvider = providers[0];
|
||||
|
||||
await TeamDomain.create({
|
||||
teamId: existingTeam.id,
|
||||
name: "other.com",
|
||||
createdById: admin.id,
|
||||
});
|
||||
|
||||
let error;
|
||||
|
||||
try {
|
||||
await accountProvisioner({
|
||||
ip,
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email: "jenny@example-company.com",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
username: "jtester",
|
||||
},
|
||||
team: {
|
||||
name: existingTeam.name,
|
||||
avatarUrl: existingTeam.avatarUrl,
|
||||
subdomain: "example",
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: authenticationProvider.name,
|
||||
providerId: authenticationProvider.providerId,
|
||||
},
|
||||
authentication: {
|
||||
providerId: "123456789",
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should create a new user in an existing team when the domain is allowed", async () => {
|
||||
const spy = jest.spyOn(WelcomeEmail, "schedule");
|
||||
const { admin, team } = await seed();
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
await TeamDomain.create({
|
||||
teamId: team.id,
|
||||
name: "example-company.com",
|
||||
createdById: admin.id,
|
||||
});
|
||||
|
||||
const { user, isNewUser } = await accountProvisioner({
|
||||
ip,
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email: "jenny@example-company.com",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
username: "jtester",
|
||||
},
|
||||
team: {
|
||||
name: team.name,
|
||||
avatarUrl: team.avatarUrl,
|
||||
subdomain: "example",
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: authenticationProvider.name,
|
||||
providerId: authenticationProvider.providerId,
|
||||
},
|
||||
authentication: {
|
||||
providerId: "123456789",
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
const authentications = await user.$get("authentications");
|
||||
const auth = authentications[0];
|
||||
expect(auth.accessToken).toEqual("123");
|
||||
expect(auth.scopes.length).toEqual(1);
|
||||
expect(auth.scopes[0]).toEqual("read");
|
||||
expect(user.email).toEqual("jenny@example-company.com");
|
||||
expect(user.username).toEqual("jtester");
|
||||
expect(isNewUser).toEqual(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
// should provision welcome collection
|
||||
const collectionCount = await Collection.count();
|
||||
expect(collectionCount).toEqual(1);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("should create a new user in an existing team", async () => {
|
||||
const spy = jest.spyOn(WelcomeEmail, "schedule");
|
||||
const team = await buildTeam();
|
||||
@@ -255,7 +157,7 @@ describe("accountProvisioner", () => {
|
||||
ip,
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email: "jenny@example-company.com",
|
||||
email: "jenny@example.com",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
username: "jtester",
|
||||
},
|
||||
@@ -279,7 +181,7 @@ describe("accountProvisioner", () => {
|
||||
expect(auth.accessToken).toEqual("123");
|
||||
expect(auth.scopes.length).toEqual(1);
|
||||
expect(auth.scopes[0]).toEqual("read");
|
||||
expect(user.email).toEqual("jenny@example-company.com");
|
||||
expect(user.email).toEqual("jenny@example.com");
|
||||
expect(user.username).toEqual("jtester");
|
||||
expect(isNewUser).toEqual(true);
|
||||
expect(spy).toHaveBeenCalled();
|
||||
@@ -289,81 +191,4 @@ describe("accountProvisioner", () => {
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
describe("self hosted", () => {
|
||||
it("should fail if existing team and domain not in allowed list", async () => {
|
||||
env.DEPLOYMENT = undefined;
|
||||
let error;
|
||||
const team = await buildTeam();
|
||||
|
||||
try {
|
||||
await accountProvisioner({
|
||||
ip,
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email: "jenny@example-company.com",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
username: "jtester",
|
||||
},
|
||||
team: {
|
||||
name: team.name,
|
||||
avatarUrl: team.avatarUrl,
|
||||
subdomain: "example",
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId: "example-company.com",
|
||||
},
|
||||
authentication: {
|
||||
providerId: "123456789",
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error.message).toEqual(
|
||||
"The maximum number of teams has been reached"
|
||||
);
|
||||
});
|
||||
|
||||
it("should always use existing team if self-hosted", async () => {
|
||||
env.DEPLOYMENT = undefined;
|
||||
|
||||
const team = await buildTeam();
|
||||
const { user, isNewUser } = await accountProvisioner({
|
||||
ip,
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email: "jenny@example-company.com",
|
||||
avatarUrl: "https://example.com/avatar.png",
|
||||
username: "jtester",
|
||||
},
|
||||
team: {
|
||||
name: team.name,
|
||||
avatarUrl: team.avatarUrl,
|
||||
subdomain: "example",
|
||||
domain: "allowed-domain.com",
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: "google",
|
||||
providerId: "allowed-domain.com",
|
||||
},
|
||||
authentication: {
|
||||
providerId: "123456789",
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(user.teamId).toEqual(team.id);
|
||||
expect(user.username).toEqual("jtester");
|
||||
expect(isNewUser).toEqual(true);
|
||||
|
||||
const providers = await team.$get("authenticationProviders");
|
||||
expect(providers.length).toEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ type Props = {
|
||||
scopes: string[];
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
expiresIn?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -84,9 +83,6 @@ async function accountProvisioner({
|
||||
ip,
|
||||
authentication: {
|
||||
...authenticationParams,
|
||||
expiresAt: authenticationParams.expiresIn
|
||||
? new Date(Date.now() + authenticationParams.expiresIn * 1000)
|
||||
: undefined,
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
},
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user