Compare commits

..

11 Commits

Author SHA1 Message Date
Saumya Pandey c584096d59 Merge branch 'main' of https://github.com/outline/outline into feat/undo-document-move 2022-04-30 13:05:09 +05:30
Saumya Pandey 5a3d55bc58 fix: delete moveWithUndo 2022-03-05 23:10:08 +05:30
Tom Moor eaff7d933e Merge branch 'main' into feat/undo-document-move 2022-03-04 18:29:50 -08:00
Saumya Pandey 21b378b80d style action in toast 2022-02-12 10:59:50 +05:30
Saumya Pandey c143036374 remove async 2022-02-12 10:21:23 +05:30
Saumya Pandey a773516e01 lighten up DragObject 2022-02-12 10:18:51 +05:30
Saumya Pandey c7045b0c00 create moveWithUndo inside document model 2022-02-12 10:02:24 +05:30
Saumya Pandey 53d0cdd151 fix: remove undo state from server 2022-02-10 01:24:56 +05:30
Saumya Pandey a22e50cd3d fix: move to client side 2022-02-10 00:42:27 +05:30
Saumya Pandey 00f65ce29d fix: undo handling for all the documents.move op 2022-02-07 23:33:03 +05:30
Saumya Pandey da8936e9d8 fix: return undo state in response 2022-02-06 14:30:16 +05:30
309 changed files with 3916 additions and 7778 deletions
+6 -10
View File
@@ -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
+22
View File
@@ -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
+3 -3
View File
@@ -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
-29
View File
@@ -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.*, ',') }}
+2 -2
View File
@@ -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
+7 -3
View File
@@ -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
}
}
}
}
+20
View File
@@ -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;
}
+1 -1
View File
@@ -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"};
`};
`;
+2 -3
View File
@@ -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)
),
+1 -1
View File
@@ -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;
+5 -8
View File
@@ -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);
+10 -11
View File
@@ -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};
}
}
`};
+1 -1
View File
@@ -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} />}
+23 -21
View File
@@ -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 -15
View File
@@ -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 -2
View File
@@ -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",
});
+2 -2
View File
@@ -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 -2
View File
@@ -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;
+5 -42
View File
@@ -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 || ""}
/>
+3 -3
View File
@@ -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) {
+1 -10
View File
@@ -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}
+1 -9
View File
@@ -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")};
+1 -1
View File
@@ -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
+1 -9
View File
@@ -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;
-23
View File
@@ -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;
+11 -13
View File
@@ -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,
});
+2 -7
View File
@@ -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>
) : (
+2 -9
View File
@@ -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;
+6 -6
View File
@@ -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} />
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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 />
+2 -1
View File
@@ -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,
]);
+5 -5
View File
@@ -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)};
}
`;
+1 -1
View File
@@ -21,7 +21,7 @@ const searcher = new FuzzySearch<{
sort: true,
});
class EmojiMenu extends React.PureComponent<
class EmojiMenu extends React.Component<
Omit<
Props<Emoji>,
| "renderMenuItem"
+12 -6
View File
@@ -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}
+6 -23
View File
@@ -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
View File
@@ -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() {
+3 -3
View File
@@ -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,
},
+5 -4
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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>
+58 -45
View File
@@ -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>
</>
)}
</>
);
}
+3 -1
View File
@@ -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]
);
+2 -5
View File
@@ -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 });
+24 -7
View File
@@ -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
View File
@@ -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);
};
-4
View File
@@ -55,10 +55,6 @@ class Team extends BaseModel {
url: string;
@Field
@observable
allowedDomains: string[] | null | undefined;
@computed
get signinMethods(): string {
return "SSO";
+5 -15
View File
@@ -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>
-2
View File
@@ -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>
);
}
+1 -1
View File
@@ -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("Im 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("Im sure Delete")}
</Button>
</form>
</Flex>
);
}
export default observer(CollectionDeleteDialog);
export default observer(CollectionDelete);
+1 -1
View File
@@ -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",
},
{
+10 -28
View File
@@ -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>
);
}
+2 -6
View File
@@ -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>
+35 -30
View File
@@ -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 -21
View File
@@ -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 (
+1 -1
View File
@@ -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
+1 -1
View File
@@ -445,4 +445,4 @@ const Label = styled.dd`
color: ${(props) => props.theme.textSecondary};
`;
export default React.memo(KeyboardShortcuts);
export default KeyboardShortcuts;
-123
View File
@@ -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;
+11 -14
View File
@@ -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>
)}
</>
);
}
+136
View File
@@ -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
View File
@@ -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%;
+1 -1
View File
@@ -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 -6
View File
@@ -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.")
+15 -64
View File
@@ -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("Im 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);
+1 -1
View File
@@ -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;
+11 -4
View File
@@ -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 Outlines 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 (
+3 -108
View File
@@ -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);
+2 -9
View File
@@ -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
View File
@@ -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,
+3 -6
View File
@@ -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 {
+12 -20
View File
@@ -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);
};
+2 -2
View File
@@ -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) {
+14
View File
@@ -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
View File
@@ -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 */
-2
View File
@@ -39,5 +39,3 @@ const locales = {
export function dateLocale(userLocale: string | null | undefined) {
return userLocale ? locales[userLocale] : undefined;
}
export { locales };
-8
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
import env from "~/env";
const isHosted = env.DEPLOYMENT === "hosted";
export default isHosted;
-31
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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

+1 -1
View File
@@ -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) {
+1 -2
View File
@@ -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,
}
+7 -182
View File
@@ -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);
});
});
});
-4
View File
@@ -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