Compare commits

..

22 Commits

Author SHA1 Message Date
Tom Moor f8a3670475 lint 2021-06-08 20:20:45 -07:00
Tom Moor 329d7b6a9c attempt defaults 2021-06-08 20:11:05 -07:00
Tom Moor e40b107504 shrug2 2021-06-06 12:20:57 -07:00
Tom Moor d9845c10d5 dupe 2021-06-06 12:03:24 -07:00
Tom Moor 3946231975 shrug 2021-06-06 11:29:17 -07:00
Tom Moor 3cbe32a46c Trying different splitchunks config 2021-06-05 18:48:43 -07:00
Tom Moor 5b48af4b95 Merge branch 'main' into fix/2163 2021-06-05 09:55:16 -07:00
Tom Moor 7e1a0f5580 fix: date-fns imports back to regular style 2021-06-04 18:27:35 -07:00
Tom Moor d12e04d650 Merge branch 'main' into fix/2163 2021-06-04 18:19:07 -07:00
Saumya Pandey 7f19c16b34 Import functions from date-fns/esm in app 2021-06-04 17:42:06 +05:30
Saumya Pandey 761959ff7e Revert "Use more specifics format functions"
This reverts commit 43d1002402.
2021-06-04 15:11:57 +05:30
Saumya Pandey 8eb5fe6eaf Merge branch 'fix/2163' of https://github.com/outline/outline into fix/2163 2021-06-04 14:08:29 +05:30
Saumya Pandey 43d1002402 Use more specifics format functions 2021-06-04 14:08:09 +05:30
Tom Moor 4941971064 Merge branch 'main' into fix/2163 2021-06-03 22:03:23 -07:00
Saumya Pandey e7dab890fe Revert "Using dynamic import for locale"
This reverts commit 7f0fc684cf.
2021-06-03 17:43:49 +05:30
Saumya Pandey 7f0fc684cf Using dynamic import for locale 2021-06-03 17:14:07 +05:30
Saumya Pandey 846a39d3d3 Change import style 2021-06-03 11:44:27 +05:30
Saumya Pandey 4c182ec869 Merge branch 'main' of https://github.com/outline/outline into fix/2163 2021-06-03 02:51:28 +05:30
Saumya Pandey a21aac1d24 Add pt_BR to locales object 2021-06-03 02:40:50 +05:30
Saumya Pandey c2959dc63a Upgrade date-fns package 2021-06-03 02:18:24 +05:30
Saumya Pandey 6104156915 Move up portuguese, brazilian 2021-06-02 11:32:48 +05:30
Saumya Pandey c527ce493d Add Portugese, Brazil to language options 2021-06-02 11:27:14 +05:30
143 changed files with 1855 additions and 5070 deletions
+3 -4
View File
@@ -8,7 +8,8 @@
# –––––––––––––––– REQUIRED ––––––––––––––––
# Generate a hex-encoded 32-byte random key. You should use `openssl rand -hex 32`
# Generate a unique 32 character hexadecimal key. The format is important as this
# value is fed directly into encryption libraries. You should use `openssl rand -hex 32`
# in your terminal to generate a random value.
SECRET_KEY=generate_a_new_key
@@ -100,7 +101,7 @@ MAXIMUM_IMPORT_SIZE=5120000
# You may enable or disable debugging categories to increase the noisiness of
# logs. The default is a good balance
DEBUG=cache,presenters,events,emails,mailer,utils,http,server,services
DEBUG=cache,presenters,events,emails,mailer,utils,multiplayer,server,services
# 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
@@ -128,8 +129,6 @@ SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_REPLY_EMAIL=
SMTP_TLS_CIPHERS=
SMTP_SECURE=true
# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png
+1 -2
View File
@@ -96,8 +96,7 @@ For contributing features and fixes you can quickly get an environment running u
1. `SLACK_KEY` (this is called "Client ID" in Slack admin)
1. `SLACK_SECRET` (this is called "Client Secret" in Slack admin)
1. Configure your Slack app's Oauth & Permissions settings
1. Slack recently prevented the use of `http` protocol for localhost. For local development, you can use a tool like [ngrok](https://ngrok.com) or a package like `mkcert`. ([How to use HTTPS for local development](https://web.dev/how-to-use-local-https/))
1. Add `https://my_ngrok_address/auth/slack.callback` as an Oauth redirect URL
1. Add `http://localhost:3000/auth/slack.callback` as an Oauth redirect URL
1. Ensure that the bot token scope contains at least `users:read`
1. Run `make up`. This will download dependencies, build and launch a development version of Outline
-9
View File
@@ -135,15 +135,6 @@
"description": "wikireply@example.com (optional)",
"required": false
},
"SMTP_SECURE": {
"value": "true",
"description": "Use a secure SMTP connection (optional)",
"required": false
},
"SMTP_TLS_CIPHERS": {
"description": "Override SMTP cipher configuration (optional)",
"required": false
},
"GOOGLE_ANALYTICS_ID": {
"description": "UA-xxxx (optional)",
"required": false
-30
View File
@@ -1,30 +0,0 @@
{
"testURL": "http://localhost",
"verbose": false,
"rootDir": "..",
"roots": [
"<rootDir>/app",
"<rootDir>/shared"
],
"moduleNameMapper": {
"^shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleFileExtensions": [
"js",
"jsx",
"json"
],
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"<rootDir>/app"
],
"setupFiles": [
"<rootDir>/__mocks__/window.js"
],
"setupFilesAfterEnv": [
"./app/test/setup.js"
]
}
+1 -3
View File
@@ -9,9 +9,7 @@ type Props = {
export default class Analytics extends React.Component<Props> {
componentDidMount() {
if (!env.GOOGLE_ANALYTICS_ID) {
return null;
}
if (!env.GOOGLE_ANALYTICS_ID) return;
// standard Google Analytics script
window.ga =
+7 -15
View File
@@ -29,26 +29,15 @@ const MenuItem = ({
const handleClick = React.useCallback(
(ev) => {
if (onClick) {
ev.preventDefault();
ev.stopPropagation();
onClick(ev);
}
if (hide) {
hide();
}
},
[onClick, hide]
[hide, onClick]
);
// Preventing default mousedown otherwise menu items do not work in Firefox,
// which triggers the hideOnClickOutside handler first via mousedown hiding
// and un-rendering the menu contents.
const handleMouseDown = React.useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
}, []);
return (
<BaseMenuItem
onClick={disabled ? undefined : onClick}
@@ -62,7 +51,6 @@ const MenuItem = ({
$toggleable={selected !== undefined}
as={onClick ? "button" : as}
onClick={handleClick}
onMouseDown={handleMouseDown}
>
{selected !== undefined && (
<>
@@ -113,8 +101,7 @@ export const MenuAnchor = styled.a`
? "pointer-events: none;"
: `
&:hover,
&:focus,
&:hover,
&.focus-visible {
color: ${props.theme.white};
background: ${props.theme.primary};
@@ -125,6 +112,11 @@ export const MenuAnchor = styled.a`
fill: ${props.theme.white};
}
}
&:focus {
color: ${props.theme.white};
background: ${props.theme.primary};
}
`};
${breakpoint("tablet")`
+2 -6
View File
@@ -83,7 +83,7 @@ const Submenu = React.forwardRef(({ templateItems, title, ...rest }, ref) => {
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
function Template({ items, ...menu }: Props): React.Node {
let filtered = items.filter((item) => item.visible !== false);
// this block literally just trims unneccessary separators
@@ -101,11 +101,7 @@ export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return [...acc, item];
}, []);
return filtered;
}
function Template({ items, ...menu }: Props): React.Node {
return filterTemplateItems(items).map((item, index) => {
return filtered.map((item, index) => {
if (item.to) {
return (
<MenuItem
+1 -1
View File
@@ -46,7 +46,7 @@ export default function ContextMenu({
<Menu hideOnClickOutside preventBodyScroll {...rest}>
{(props) => (
<Position {...props}>
<Background dir="auto">
<Background>
{rest.visible || rest.animating ? children : null}
</Background>
</Position>
-1
View File
@@ -15,7 +15,6 @@ class CopyToClipboard extends React.PureComponent<Props> {
const elem = React.Children.only(children);
copy(text, {
debug: process.env.NODE_ENV !== "production",
format: "text/plain",
});
if (onCopy) onCopy();
+2 -3
View File
@@ -67,7 +67,6 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
id: document.collectionId,
name: t("Deleted Collection"),
color: "currentColor",
url: "deleted-collection",
};
}
@@ -90,7 +89,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
output.push({
icon: <CollectionIcon collection={collection} expanded />,
title: collection.name,
to: collectionUrl(collection.url),
to: collectionUrl(collection.id),
});
}
@@ -105,7 +104,7 @@ const DocumentBreadcrumb = ({ document, children, onlyText }: Props) => {
}, [path, category, collection]);
if (!collections.isLoaded) {
return null;
return;
}
if (onlyText === true) {
+4 -11
View File
@@ -41,7 +41,7 @@ function replaceResultMarks(tag: string) {
return tag.replace(/<b\b[^>]*>(.*?)<\/b>/gi, "$1");
}
function DocumentListItem(props: Props, ref) {
function DocumentListItem(props: Props) {
const { t } = useTranslation();
const { policies } = useStores();
const currentUser = useCurrentUser();
@@ -68,8 +68,6 @@ function DocumentListItem(props: Props, ref) {
return (
<DocumentLink
ref={ref}
dir={document.dir}
$isStarred={document.isStarred}
$menuOpen={menuOpen}
to={{
@@ -78,12 +76,8 @@ function DocumentListItem(props: Props, ref) {
}}
>
<Content>
<Heading dir={document.dir}>
<Title
text={document.titleWithDefault}
highlight={highlight}
dir={document.dir}
/>
<Heading>
<Title text={document.titleWithDefault} highlight={highlight} />
{document.isNew && document.createdBy.id !== currentUser.id && (
<Badge yellow>{t("New")}</Badge>
)}
@@ -227,7 +221,6 @@ const DocumentLink = styled(Link)`
const Heading = styled.h3`
display: flex;
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
align-items: center;
height: 24px;
margin-top: 0;
@@ -258,4 +251,4 @@ const ResultContext = styled(Highlight)`
margin-bottom: 0.25em;
`;
export default observer(React.forwardRef(DocumentListItem));
export default observer(DocumentListItem);
+1 -2
View File
@@ -11,7 +11,6 @@ import Time from "components/Time";
import useStores from "hooks/useStores";
const Container = styled(Flex)`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
color: ${(props) => props.theme.textTertiary};
font-size: 13px;
white-space: nowrap;
@@ -136,7 +135,7 @@ function DocumentMeta({
: 0;
return (
<Container align="center" rtl={document.dir === "rtl"} {...rest} dir="ltr">
<Container align="center" {...rest}>
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
-8
View File
@@ -14,7 +14,6 @@ type Props = {|
document: Document,
isDraft: boolean,
to?: string,
rtl?: boolean,
|};
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
@@ -24,12 +23,6 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
const totalViewers = documentViews.length;
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
React.useEffect(() => {
if (!document.isDeleted) {
views.fetchPage({ documentId: document.id });
}
}, [views, document.id, document.isDeleted]);
const popover = usePopoverState({
gutter: 8,
placement: "bottom",
@@ -63,7 +56,6 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
}
const Meta = styled(DocumentMeta)`
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
margin: -12px 0 2em 0;
font-size: 14px;
position: relative;
+5 -1
View File
@@ -19,6 +19,10 @@ function DocumentViews({ document, isOpen }: Props) {
const { t } = useTranslation();
const { views, presence } = useStores();
React.useEffect(() => {
views.fetchPage({ documentId: document.id });
}, [views, document.id]);
let documentPresence = presence.get(document.id);
documentPresence = documentPresence
? Array.from(documentPresence.values())
@@ -56,7 +60,7 @@ function DocumentViews({ document, isOpen }: Props) {
: t("Currently viewing")
: t("Viewed {{ timeAgo }} ago", {
timeAgo: formatDistanceToNow(
view ? Date.parse(view.lastViewedAt) : new Date()
view ? new Date(view.lastViewedAt) : new Date()
),
});
-4
View File
@@ -16,10 +16,6 @@ const Select = styled.select`
color: ${(props) => props.theme.text};
height: 30px;
option {
background: ${(props) => props.theme.buttonNeutralBackground};
}
&:disabled,
&::placeholder {
color: ${(props) => props.theme.placeholder};
+1 -14
View File
@@ -1,18 +1,6 @@
// @flow
import { format, formatDistanceToNow } from "date-fns";
import {
enUS,
de,
fr,
es,
it,
ko,
ptBR,
pt,
zhCN,
zhTW,
ru,
} from "date-fns/locale";
import { enUS, de, fr, es, it, ko, ptBR, pt, zhCN, ru } from "date-fns/locale";
import * as React from "react";
import Tooltip from "components/Tooltip";
import useUserLocale from "hooks/useUserLocale";
@@ -27,7 +15,6 @@ const locales = {
pt_BR: ptBR,
pt_PT: pt,
zh_CN: zhCN,
zh_TW: zhTW,
ru_RU: ru,
};
+4 -14
View File
@@ -38,24 +38,14 @@ class PaginatedList extends React.Component<Props> {
}
componentDidUpdate(prevProps: Props) {
if (
prevProps.fetch !== this.props.fetch ||
!isEqual(prevProps.options, this.props.options)
) {
this.reset();
if (prevProps.fetch !== this.props.fetch) {
this.fetchResults();
}
if (!isEqual(prevProps.options, this.props.options)) {
this.fetchResults();
}
}
reset = () => {
this.offset = 0;
this.allowLoadMore = true;
this.renderCount = DEFAULT_PAGINATION_LIMIT;
this.isFetching = false;
this.isFetchingMore = false;
this.isLoaded = false;
};
fetchResults = async () => {
if (!this.props.fetch) return;
-84
View File
@@ -1,84 +0,0 @@
// @flow
import "../stores";
import { shallow } from "enzyme";
import * as React from "react";
import { DEFAULT_PAGINATION_LIMIT } from "stores/BaseStore";
import { runAllPromises } from "../test/support";
import PaginatedList from "./PaginatedList";
describe("PaginatedList", () => {
const render = () => null;
it("with no items renders nothing", () => {
const list = shallow(<PaginatedList items={[]} renderItem={render} />);
expect(list).toEqual({});
});
it("with no items renders empty prop", () => {
const list = shallow(
<PaginatedList
items={[]}
empty={<p>Sorry, no results</p>}
renderItem={render}
/>
);
expect(list.text()).toEqual("Sorry, no results");
});
it("calls fetch with options + pagination on mount", () => {
const fetch = jest.fn();
const options = { id: "one" };
shallow(
<PaginatedList
items={[]}
fetch={fetch}
options={options}
renderItem={render}
/>
);
expect(fetch).toHaveBeenCalledWith({
...options,
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
});
it("calls fetch when options prop changes", async () => {
const fetchedItems = Array(DEFAULT_PAGINATION_LIMIT).fill();
const fetch = jest.fn().mockReturnValue(fetchedItems);
const list = shallow(
<PaginatedList
items={[]}
fetch={fetch}
options={{ id: "one" }}
renderItem={render}
/>
);
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "one",
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
fetch.mockReset();
list.setProps({
fetch,
items: [],
options: { id: "two" },
});
await runAllPromises();
expect(fetch).toHaveBeenCalledWith({
id: "two",
limit: DEFAULT_PAGINATION_LIMIT,
offset: 0,
});
});
});
@@ -27,19 +27,16 @@ type Props = {|
parentId?: string,
|};
function DocumentLink(
{
node,
canUpdate,
collection,
activeDocument,
prefetchDocument,
depth,
index,
parentId,
}: Props,
ref
) {
function DocumentLink({
node,
canUpdate,
collection,
activeDocument,
prefetchDocument,
depth,
index,
parentId,
}: Props) {
const { documents, policies } = useStores();
const { t } = useTranslation();
@@ -239,7 +236,6 @@ function DocumentLink(
depth={depth}
exact={false}
showActions={menuOpen}
ref={ref}
menu={
document && !isMoving ? (
<Fade>
@@ -293,6 +289,5 @@ const Disclosure = styled(CollapsedIcon)`
${({ expanded }) => !expanded && "transform: rotate(-90deg);"};
`;
const ObservedDocumentLink = observer(React.forwardRef(DocumentLink));
const ObservedDocumentLink = observer(DocumentLink);
export default ObservedDocumentLink;
@@ -65,7 +65,6 @@ function EditableTitle({ title, onSubmit, canUpdate }: Props) {
{isEditing ? (
<form onSubmit={handleSave}>
<Input
dir="auto"
type="text"
value={value}
onKeyDown={handleKeyDown}
@@ -7,7 +7,7 @@ const ResizeBorder = styled.div`
bottom: 0;
right: -6px;
width: 12px;
cursor: col-resize;
cursor: ew-resize;
`;
export default ResizeBorder;
@@ -1,5 +1,4 @@
// @flow
import { transparentize } from "polished";
import * as React from "react";
import { withRouter, type RouterHistory, type Match } from "react-router-dom";
import styled, { withTheme } from "styled-components";
@@ -30,28 +29,25 @@ type Props = {
depth?: number,
};
function SidebarLink(
{
icon,
children,
onClick,
onMouseEnter,
to,
label,
active,
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
}: Props,
ref
) {
function SidebarLink({
icon,
children,
onClick,
onMouseEnter,
to,
label,
active,
isActiveDrop,
menu,
showActions,
theme,
exact,
href,
depth,
history,
match,
className,
}: Props) {
const style = React.useMemo(() => {
return {
paddingLeft: `${(depth || 0) * 16 + 16}px`,
@@ -82,7 +78,6 @@ function SidebarLink(
as={to ? undefined : href ? "a" : "div"}
href={href}
className={className}
ref={ref}
>
{icon && <IconWrapper>{icon}</IconWrapper>}
<Label>{label}</Label>
@@ -144,33 +139,30 @@ const Link = styled(NavLink)`
transition: fill 50ms;
}
&:hover {
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
&:focus {
color: ${(props) => props.theme.text};
background: ${(props) =>
transparentize("0.25", props.theme.sidebarItemBackground)};
background: ${(props) => props.theme.black05};
}
&:hover + ${Actions},
&:active + ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
}
}
${breakpoint("tablet")`
padding: 4px 32px 4px 16px;
font-size: 15px;
`}
@media (hover: hover) {
&:hover + ${Actions},
&:active + ${Actions} {
display: inline-flex;
svg {
opacity: 0.75;
}
}
}
&:hover {
color: ${(props) =>
props.$isActiveDrop ? props.theme.white : props.theme.text};
}
}
`;
const Label = styled.div`
@@ -178,9 +170,6 @@ const Label = styled.div`
width: 100%;
max-height: 4.8em;
line-height: 1.6;
* {
unicode-bidi: plaintext;
}
`;
export default withRouter(withTheme(React.forwardRef(SidebarLink)));
export default withRouter(withTheme(SidebarLink));
-4
View File
@@ -250,10 +250,6 @@ class SocketProvider extends React.Component<Props> {
documents.starredIds.set(event.documentId, false);
});
this.socket.on("documents.permanent_delete", (event) => {
documents.remove(event.documentId);
});
// received when a user is given access to a collection
// if the user is us then we go ahead and load the collection from API.
this.socket.on("collections.add_user", (event) => {
+1 -4
View File
@@ -17,10 +17,7 @@ export default class Mindmeister extends React.Component<Props> {
static ENABLED = [URL_REGEX];
render() {
const chartId =
this.props.attrs.matches[4] +
(this.props.attrs.matches[5] || "") +
(this.props.attrs.matches[6] || "");
const chartId = this.props.attrs.matches[4] + this.props.attrs.matches[6];
return (
<Frame
+9 -9
View File
@@ -11,7 +11,9 @@ import Flex from "components/Flex";
const Iframe = (props) => <iframe title="Embed" {...props} />;
const StyledIframe = styled(Iframe)`
border-radius: ${(props) => (props.$withBar ? "3px 3px 0 0" : "3px")};
border: 1px solid;
border-color: ${(props) => props.theme.embedBorder};
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
display: block;
`;
@@ -68,13 +70,13 @@ class Frame extends React.Component<PropsWithRef> {
<Rounded
width={width}
height={height}
$withBar={withBar}
withBar={withBar}
className={isSelected ? "ProseMirror-selectednode" : ""}
>
{this.isLoaded && (
<Component
ref={forwardedRef}
$withBar={withBar}
withBar={withBar}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
width={width}
height={height}
@@ -106,11 +108,10 @@ class Frame extends React.Component<PropsWithRef> {
}
const Rounded = styled.div`
border: 1px solid ${(props) => props.theme.embedBorder};
border-radius: 6px;
border-radius: ${(props) => (props.withBar ? "3px 3px 0 0" : "3px")};
overflow: hidden;
width: ${(props) => props.width};
height: ${(props) => (props.$withBar ? props.height + 28 : props.height)};
height: ${(props) => (props.withBar ? props.height + 28 : props.height)};
`;
const Open = styled.a`
@@ -131,12 +132,11 @@ const Title = styled.span`
`;
const Bar = styled(Flex)`
border-top: 1px solid ${(props) => props.theme.embedBorder};
background: ${(props) => props.theme.secondaryBackground};
color: ${(props) => props.theme.textSecondary};
padding: 0 8px;
border-bottom-left-radius: 6px;
border-bottom-right-radius: 6px;
border-bottom-left-radius: 3px;
border-bottom-right-radius: 3px;
user-select: none;
`;
-52
View File
@@ -1,52 +0,0 @@
// @flow
// Based on https://github.com/rehooks/window-scroll-position which is no longer
// maintained.
import { throttle } from "lodash";
import { useState, useEffect } from "react";
let supportsPassive = false;
try {
var opts = Object.defineProperty({}, "passive", {
get: function () {
supportsPassive = true;
},
});
window.addEventListener("testPassive", null, opts);
window.removeEventListener("testPassive", null, opts);
} catch (e) {}
const getPosition = () => ({
x: window.pageXOffset,
y: window.pageYOffset,
});
const defaultOptions = {
throttle: 100,
};
export default function useWindowScrollPosition(options: {
throttle: number,
}): { x: number, y: number } {
let opts = Object.assign({}, defaultOptions, options);
let [position, setPosition] = useState(getPosition());
useEffect(() => {
let handleScroll = throttle(() => {
setPosition(getPosition());
}, opts.throttle);
window.addEventListener(
"scroll",
handleScroll,
supportsPassive ? { passive: true } : false
);
return () => {
handleScroll.cancel();
window.removeEventListener("scroll", handleScroll);
};
}, [opts.throttle]);
return position;
}
+18 -20
View File
@@ -51,25 +51,23 @@ if ("serviceWorker" in window.navigator) {
if (element) {
const App = () => (
<React.StrictMode>
<Provider {...stores}>
<Analytics>
<Theme>
<ErrorBoundary>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</ErrorBoundary>
</Theme>
</Analytics>
</Provider>
</React.StrictMode>
<Provider {...stores}>
<Analytics>
<Theme>
<ErrorBoundary>
<Router history={history}>
<>
<PageTheme />
<ScrollToTop>
<Routes />
</ScrollToTop>
<Toasts />
</>
</Router>
</ErrorBoundary>
</Theme>
</Analytics>
</Provider>
);
render(<App />, element);
@@ -81,7 +79,7 @@ window.addEventListener("load", async () => {
if (!env.GOOGLE_ANALYTICS_ID || !window.ga) return;
// https://github.com/googleanalytics/autotrack/issues/137#issuecomment-305890099
await import(/* webpackChunkName: "autotrack" */ "autotrack/autotrack.js");
await import(/** webpackChunkName: "autotrack" */ "autotrack/autotrack.js");
window.ga("require", "outboundLinkTracker");
window.ga("require", "urlChangeTracker");
+42 -48
View File
@@ -12,7 +12,7 @@ import CollectionExport from "scenes/CollectionExport";
import CollectionPermissions from "scenes/CollectionPermissions";
import ContextMenu from "components/ContextMenu";
import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template, { filterTemplateItems } from "components/ContextMenu/Template";
import Template from "components/ContextMenu/Template";
import Modal from "components/Modal";
import useStores from "hooks/useStores";
import getDataTransferFiles from "utils/getDataTransferFiles";
@@ -110,52 +110,6 @@ function CollectionMenu({
);
const can = policies.abilities(collection.id);
const items = React.useMemo(
() =>
filterTemplateItems([
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]),
[can, collection, handleNewDocument, handleImportDocument, t]
);
if (!items.length) {
return null;
}
return (
<>
@@ -180,7 +134,47 @@ function CollectionMenu({
onClose={onClose}
aria-label={t("Collection")}
>
<Template {...menu} items={items} />
<Template
{...menu}
items={[
{
title: t("New document"),
visible: can.update,
onClick: handleNewDocument,
},
{
title: t("Import document"),
visible: can.update,
onClick: handleImportDocument,
},
{
type: "separator",
},
{
title: `${t("Edit")}`,
visible: can.update,
onClick: () => setShowCollectionEdit(true),
},
{
title: `${t("Permissions")}`,
visible: can.update,
onClick: () => setShowCollectionPermissions(true),
},
{
title: `${t("Export")}`,
visible: !!(collection && can.export),
onClick: () => setShowCollectionExport(true),
},
{
type: "separator",
},
{
title: `${t("Delete")}`,
visible: !!(collection && can.delete),
onClick: () => setShowCollectionDelete(true),
},
]}
/>
</ContextMenu>
{renderModals && (
<>
+39 -64
View File
@@ -9,7 +9,6 @@ import styled from "styled-components";
import Document from "models/Document";
import DocumentDelete from "scenes/DocumentDelete";
import DocumentMove from "scenes/DocumentMove";
import DocumentPermanentDelete from "scenes/DocumentPermanentDelete";
import DocumentTemplatize from "scenes/DocumentTemplatize";
import CollectionIcon from "components/CollectionIcon";
import ContextMenu from "components/ContextMenu";
@@ -62,10 +61,6 @@ function DocumentMenu({
const { t } = useTranslation();
const [renderModals, setRenderModals] = React.useState(false);
const [showDeleteModal, setShowDeleteModal] = React.useState(false);
const [
showPermanentDeleteModal,
setShowPermanentDeleteModal,
] = React.useState(false);
const [showMoveModal, setShowMoveModal] = React.useState(false);
const [showTemplateModal, setShowTemplateModal] = React.useState(false);
const file = React.useRef<?HTMLInputElement>();
@@ -223,7 +218,12 @@ function DocumentMenu({
items={[
{
title: t("Restore"),
visible: (!!collection && can.restore) || can.unarchive,
visible: !!can.unarchive,
onClick: handleRestore,
},
{
title: t("Restore"),
visible: !!(collection && can.restore),
onClick: handleRestore,
},
{
@@ -332,11 +332,6 @@ function DocumentMenu({
onClick: () => setShowDeleteModal(true),
visible: !!can.delete,
},
{
title: `${t("Permanently delete")}`,
onClick: () => setShowPermanentDeleteModal(true),
visible: can.permanentDelete,
},
{
title: `${t("Move")}`,
onClick: () => setShowMoveModal(true),
@@ -367,60 +362,40 @@ function DocumentMenu({
</ContextMenu>
{renderModals && (
<>
{can.move && (
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
<Modal
title={t("Move {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowMoveModal(false)}
isOpen={showMoveModal}
>
<DocumentMove
document={document}
onRequestClose={() => setShowMoveModal(false)}
isOpen={showMoveModal}
>
<DocumentMove
document={document}
onRequestClose={() => setShowMoveModal(false)}
/>
</Modal>
)}
{can.delete && (
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
)}
{can.permanentDelete && (
<Modal
title={t("Permanently delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowPermanentDeleteModal(false)}
isOpen={showPermanentDeleteModal}
>
<DocumentPermanentDelete
document={document}
onSubmit={() => setShowPermanentDeleteModal(false)}
/>
</Modal>
)}
{can.update && (
<Modal
title={t("Create template")}
onRequestClose={() => setShowTemplateModal(false)}
isOpen={showTemplateModal}
>
<DocumentTemplatize
document={document}
onSubmit={() => setShowTemplateModal(false)}
/>
</Modal>
)}
/>
</Modal>
<Modal
title={t("Delete {{ documentName }}", {
documentName: document.noun,
})}
onRequestClose={() => setShowDeleteModal(false)}
isOpen={showDeleteModal}
>
<DocumentDelete
document={document}
onSubmit={() => setShowDeleteModal(false)}
/>
</Modal>
<Modal
title={t("Create template")}
onRequestClose={() => setShowTemplateModal(false)}
isOpen={showTemplateModal}
>
<DocumentTemplatize
document={document}
onSubmit={() => setShowTemplateModal(false)}
/>
</Modal>
</>
)}
</>
+1 -1
View File
@@ -25,7 +25,7 @@ function NewDocumentMenu() {
const can = policies.abilities(team.id);
if (!can.createDocument) {
return null;
return;
}
if (singleCollection) {
+1 -1
View File
@@ -23,7 +23,7 @@ function NewTemplateMenu() {
const can = policies.abilities(team.id);
if (!can.createDocument) {
return null;
return;
}
return (
+16 -32
View File
@@ -9,7 +9,6 @@ import Document from "models/Document";
import Button from "components/Button";
import ContextMenu from "components/ContextMenu";
import MenuItem from "components/ContextMenu/MenuItem";
import Separator from "components/ContextMenu/Separator";
import useStores from "hooks/useStores";
type Props = {|
@@ -20,36 +19,12 @@ function TemplatesMenu({ document }: Props) {
const menu = useMenuState({ modal: true });
const { documents } = useStores();
const { t } = useTranslation();
const templates = documents.templates;
const templates = documents.templatesInCollection(document.collectionId);
if (!templates.length) {
return null;
}
const templatesInCollection = templates.filter(
(t) => t.collectionId === document.collectionId
);
const otherTemplates = templates.filter(
(t) => t.collectionId !== document.collectionId
);
const renderTemplate = (template) => (
<MenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
{...menu}
>
<DocumentIcon />
<div>
<strong>{template.titleWithDefault}</strong>
<br />
<Author>
{t("By {{ author }}", { author: template.createdBy.name })}
</Author>
</div>
</MenuItem>
);
return (
<>
<MenuButton {...menu}>
@@ -60,11 +35,21 @@ function TemplatesMenu({ document }: Props) {
)}
</MenuButton>
<ContextMenu {...menu} aria-label={t("Templates")}>
{templatesInCollection.map(renderTemplate)}
{otherTemplates.length && templatesInCollection.length ? (
<Separator />
) : undefined}
{otherTemplates.map(renderTemplate)}
{templates.map((template) => (
<MenuItem
key={template.id}
onClick={() => document.updateFromTemplate(template)}
>
<DocumentIcon />
<div>
<strong>{template.titleWithDefault}</strong>
<br />
<Author>
{t("By {{ author }}", { author: template.createdBy.name })}
</Author>
</div>
</MenuItem>
))}
</ContextMenu>
</>
);
@@ -72,7 +57,6 @@ function TemplatesMenu({ document }: Props) {
const Author = styled.div`
font-size: 13px;
text-align: left;
`;
export default observer(TemplatesMenu);
-1
View File
@@ -24,7 +24,6 @@ export default class Collection extends BaseModel {
deletedAt: ?string;
sort: { field: string, direction: "asc" | "desc" };
url: string;
urlId: string;
@computed
get isEmpty(): boolean {
-20
View File
@@ -58,26 +58,6 @@ export default class Document extends BaseModel {
return emoji;
}
/**
* Best-guess the text direction of the document based on the language the
* title is written in. Note: wrapping as a computed getter means that it will
* only be called directly when the title changes.
*/
@computed
get dir(): "rtl" | "ltr" {
const element = document.createElement("p");
element.innerHTML = this.title;
element.style.visibility = "hidden";
element.dir = "auto";
// element must appear in body for direction to be computed
document.body?.appendChild(element);
const direction = window.getComputedStyle(element).direction;
document.body?.removeChild(element);
return direction;
}
@computed
get noun(): string {
return this.template ? "template" : "document";
+36 -37
View File
@@ -27,6 +27,7 @@ const KeyedDocument = React.lazy(() =>
/* webpackChunkName: "keyed-document" */ "scenes/Document/KeyedDocument"
)
);
const NotFound = () => <Search notFound />;
const RedirectDocument = ({ match }: { match: Match }) => (
<Redirect
@@ -40,44 +41,42 @@ export default function AuthenticatedRoutes() {
return (
<SocketProvider>
<Layout>
<React.Suspense
fallback={
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
}
>
<Switch>
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/templates" component={Templates} />
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Redirect exact from="/collections/*" to="/collection/*" />
<Route exact path="/collection/:id/new" component={DocumentNew} />
<Route exact path="/collection/:id/:tab" component={Collection} />
<Route exact path="/collection/:id" component={Collection} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
/>
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
<Switch>
<Redirect from="/dashboard" to="/home" />
<Route path="/home/:tab" component={Home} />
<Route path="/home" component={Home} />
<Route exact path="/starred" component={Starred} />
<Route exact path="/starred/:sort" component={Starred} />
<Route exact path="/templates" component={Templates} />
<Route exact path="/templates/:sort" component={Templates} />
<Route exact path="/drafts" component={Drafts} />
<Route exact path="/archive" component={Archive} />
<Route exact path="/trash" component={Trash} />
<Route exact path="/collections/:id/new" component={DocumentNew} />
<Route exact path="/collections/:id/:tab" component={Collection} />
<Route exact path="/collections/:id" component={Collection} />
<Route exact path={`/d/${slug}`} component={RedirectDocument} />
<Route
exact
path={`/doc/${slug}/history/:revisionId?`}
component={KeyedDocument}
/>
<Route exact path={`/doc/${slug}/edit`} component={KeyedDocument} />
<Route path={`/doc/${slug}`} component={KeyedDocument} />
<Route exact path="/search" component={Search} />
<Route exact path="/search/:term" component={Search} />
<Route path="/404" component={Error404} />
<React.Suspense
fallback={
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
}
>
<SettingsRoutes />
<Route component={NotFound} />
</Switch>{" "}
</React.Suspense>
</React.Suspense>
<Route component={NotFound} />
</Switch>
</Layout>
</SocketProvider>
);
-65
View File
@@ -1,65 +0,0 @@
// @flow
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import Input from "components/Input";
import useStores from "hooks/useStores";
type Props = {|
onSubmit: () => void,
|};
function APITokenNew({ onSubmit }: Props) {
const [name, setName] = React.useState("");
const [isSaving, setIsSaving] = React.useState(false);
const { apiKeys, ui } = useStores();
const { t } = useTranslation();
const handleSubmit = React.useCallback(async () => {
setIsSaving(true);
try {
await apiKeys.create({ name });
ui.showToast(t("API token created", { type: "success" }));
onSubmit();
} catch (err) {
ui.showToast(err.message, { type: "error" });
} finally {
setIsSaving(false);
}
}, [t, ui, name, onSubmit, apiKeys]);
const handleNameChange = React.useCallback((event) => {
setName(event.target.value);
}, []);
return (
<form onSubmit={handleSubmit}>
<HelpText>
<Trans>
Name your token something that will help you to remember it's use in
the future, for example "local development", "production", or
"continuous integration".
</Trans>
</HelpText>
<Flex>
<Input
type="text"
label="Name"
onChange={handleNameChange}
value={name}
required
autoFocus
flex
/>
</Flex>
<Button type="submit" disabled={isSaving || !name}>
{isSaving ? "Creating…" : "Create"}
</Button>
</form>
);
}
export default APITokenNew;
+42 -65
View File
@@ -4,15 +4,7 @@ import { NewDocumentIcon, PlusIcon, PinIcon, MoreIcon } from "outline-icons";
import * as React from "react";
import Dropzone from "react-dropzone";
import { useTranslation, Trans } from "react-i18next";
import {
useParams,
Redirect,
Link,
Switch,
Route,
useHistory,
useRouteMatch,
} from "react-router-dom";
import { useParams, Redirect, Link, Switch, Route } from "react-router-dom";
import styled, { css } from "styled-components";
import CollectionPermissions from "scenes/CollectionPermissions";
import Search from "scenes/Search";
@@ -37,18 +29,15 @@ import Subheading from "components/Subheading";
import Tab from "components/Tab";
import Tabs from "components/Tabs";
import Tooltip from "components/Tooltip";
import Collection from "../models/Collection";
import { updateCollectionUrl } from "../utils/routeHelpers";
import useCurrentTeam from "hooks/useCurrentTeam";
import useImportDocument from "hooks/useImportDocument";
import useStores from "hooks/useStores";
import useUnmount from "hooks/useUnmount";
import CollectionMenu from "menus/CollectionMenu";
import { newDocumentUrl, collectionUrl } from "utils/routeHelpers";
function CollectionScene() {
const params = useParams();
const history = useHistory();
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, policies, collections, ui } = useStores();
const team = useCurrentTeam();
@@ -56,21 +45,11 @@ function CollectionScene() {
const [error, setError] = React.useState();
const [permissionsModalOpen, setPermissionsModalOpen] = React.useState(false);
const id = params.id || "";
const collection: ?Collection =
collections.getByUrl(id) || collections.get(id);
const can = policies.abilities(collection?.id || "");
const collectionId = params.id || "";
const collection = collections.get(collectionId);
const can = policies.abilities(collectionId || "");
const canUser = policies.abilities(team.id);
const { handleFiles, isImporting } = useImportDocument(collection?.id || "");
React.useEffect(() => {
if (collection) {
const canonicalUrl = updateCollectionUrl(match.url, collection);
if (match.url !== canonicalUrl) {
history.replace(canonicalUrl);
}
}
}, [collection, history, id, match.url]);
const { handleFiles, isImporting } = useImportDocument(collectionId);
React.useEffect(() => {
if (collection) {
@@ -80,10 +59,8 @@ function CollectionScene() {
React.useEffect(() => {
setError(null);
if (collection) {
documents.fetchPinned({ collectionId: collection.id });
}
}, [documents, collection]);
documents.fetchPinned({ collectionId });
}, [documents, collectionId]);
React.useEffect(() => {
async function load() {
@@ -91,7 +68,7 @@ function CollectionScene() {
try {
setError(null);
setFetching(true);
await collections.fetch(id);
await collections.fetch(collectionId);
} catch (err) {
setError(err);
} finally {
@@ -100,7 +77,9 @@ function CollectionScene() {
}
}
load();
}, [collections, isFetching, collection, error, id, can]);
}, [collections, isFetching, collection, error, collectionId, can]);
useUnmount(ui.clearActiveCollection);
const handlePermissionsModalOpen = React.useCallback(() => {
setPermissionsModalOpen(true);
@@ -145,31 +124,29 @@ function CollectionScene() {
source="collection"
placeholder={`${t("Search in collection")}`}
label={`${t("Search in collection")}`}
collectionId={collection.id}
collectionId={collectionId}
/>
</Action>
{can.update && (
<>
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
<Action>
<Tooltip
tooltip={t("New document")}
shortcut="n"
delay={500}
placement="bottom"
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
<Button
as={Link}
to={collection ? newDocumentUrl(collection.id) : ""}
disabled={!collection}
icon={<PlusIcon />}
>
{t("New doc")}
</Button>
</Tooltip>
</Action>
<Separator />
</>
{t("New doc")}
</Button>
</Tooltip>
</Action>
)}
<Separator />
<Action>
<CollectionMenu
collection={collection}
@@ -280,27 +257,27 @@ function CollectionScene() {
)}
<Tabs>
<Tab to={collectionUrl(collection.url)} exact>
<Tab to={collectionUrl(collection.id)} exact>
{t("Documents")}
</Tab>
<Tab to={collectionUrl(collection.url, "updated")} exact>
<Tab to={collectionUrl(collection.id, "updated")} exact>
{t("Recently updated")}
</Tab>
<Tab to={collectionUrl(collection.url, "published")} exact>
<Tab to={collectionUrl(collection.id, "published")} exact>
{t("Recently published")}
</Tab>
<Tab to={collectionUrl(collection.url, "old")} exact>
<Tab to={collectionUrl(collection.id, "old")} exact>
{t("Least recently updated")}
</Tab>
<Tab
to={collectionUrl(collection.url, "alphabetical")}
to={collectionUrl(collection.id, "alphabetical")}
exact
>
{t("AZ")}
</Tab>
</Tabs>
<Switch>
<Route path={collectionUrl(collection.url, "alphabetical")}>
<Route path={collectionUrl(collection.id, "alphabetical")}>
<PaginatedDocumentList
key="alphabetical"
documents={documents.alphabeticalInCollection(
@@ -311,7 +288,7 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.url, "old")}>
<Route path={collectionUrl(collection.id, "old")}>
<PaginatedDocumentList
key="old"
documents={documents.leastRecentlyUpdatedInCollection(
@@ -322,12 +299,12 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.url, "recent")}>
<Route path={collectionUrl(collection.id, "recent")}>
<Redirect
to={collectionUrl(collection.url, "published")}
to={collectionUrl(collection.id, "published")}
/>
</Route>
<Route path={collectionUrl(collection.url, "published")}>
<Route path={collectionUrl(collection.id, "published")}>
<PaginatedDocumentList
key="published"
documents={documents.recentlyPublishedInCollection(
@@ -339,7 +316,7 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.url, "updated")}>
<Route path={collectionUrl(collection.id, "updated")}>
<PaginatedDocumentList
key="updated"
documents={documents.recentlyUpdatedInCollection(
@@ -350,7 +327,7 @@ function CollectionScene() {
showPin
/>
</Route>
<Route path={collectionUrl(collection.url)} exact>
<Route path={collectionUrl(collection.id)} exact>
<PaginatedDocumentList
documents={documents.rootInCollection(collection.id)}
fetch={documents.fetchPage}
+1 -1
View File
@@ -1,9 +1,9 @@
// @flow
import useWindowScrollPosition from "@rehooks/window-scroll-position";
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import HelpText from "components/HelpText";
import useWindowScrollPosition from "hooks/useWindowScrollPosition";
const HEADING_OFFSET = 20;
+5 -2
View File
@@ -121,7 +121,7 @@ class DataLoader extends React.Component<Props> {
return sortBy(
results.map((document) => {
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
const time = formatDistanceToNow(document.updatedAt, {
addSuffix: true,
});
return {
@@ -214,7 +214,10 @@ class DataLoader extends React.Component<Props> {
const isMove = this.props.location.pathname.match(/move$/);
const canRedirect = !revisionId && !isMove && !shareId;
if (canRedirect) {
const canonicalUrl = updateDocumentUrl(this.props.match.url, document);
const canonicalUrl = updateDocumentUrl(
this.props.match.url,
document.url
);
if (this.props.location.pathname !== canonicalUrl) {
this.props.history.replace(canonicalUrl);
}
+11 -2
View File
@@ -36,6 +36,7 @@ import { isCustomDomain } from "utils/domains";
import { emojiToUrl } from "utils/emoji";
import { meta } from "utils/keyboard";
import {
collectionUrl,
documentMoveUrl,
documentHistoryUrl,
editDocumentUrl,
@@ -173,7 +174,7 @@ class DocumentScene extends React.Component<Props> {
this.onSave({ publish: true, done: true });
}
@keydown("ctrl+alt+h")
@keydown(`${meta}+ctrl+h`)
onToggleTableOfContents(ev) {
if (!this.props.readOnly) return;
@@ -290,7 +291,15 @@ class DocumentScene extends React.Component<Props> {
};
goBack = () => {
this.props.history.push(this.props.document.url);
let url;
if (this.props.document.url) {
url = this.props.document.url;
} else if (this.props.match.params.id) {
url = collectionUrl(this.props.match.params.id);
}
if (url) {
this.props.history.push(url);
}
};
render() {
+5 -17
View File
@@ -33,7 +33,6 @@ type Props = {|
@observer
class DocumentEditor extends React.Component<Props> {
@observable activeLinkEvent: ?MouseEvent;
ref = React.createRef<HTMLDivElement | HTMLInputElement>();
focusAtStart = () => {
if (this.props.innerRef.current) {
@@ -115,10 +114,8 @@ class DocumentEditor extends React.Component<Props> {
{readOnly ? (
<Title
as="div"
ref={this.ref}
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
$isStarred={document.isStarred}
dir="auto"
>
<span>{normalizedTitle}</span>{" "}
{!shareId && <StarButton document={document} size={32} />}
@@ -126,7 +123,6 @@ class DocumentEditor extends React.Component<Props> {
) : (
<Title
type="text"
ref={this.ref}
onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown}
placeholder={document.placeholder}
@@ -134,21 +130,13 @@ class DocumentEditor extends React.Component<Props> {
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
autoFocus={!title}
maxLength={MAX_TITLE_LENGTH}
dir="auto"
/>
)}
{!shareId && (
<DocumentMetaWithViews
isDraft={isDraft}
document={document}
to={documentHistoryUrl(document)}
rtl={
this.ref.current
? window.getComputedStyle(this.ref.current).direction === "rtl"
: false
}
/>
)}
<DocumentMetaWithViews
isDraft={isDraft}
document={document}
to={documentHistoryUrl(document)}
/>
<Editor
ref={innerRef}
autoFocus={!!title && !this.props.defaultValue}
+1 -1
View File
@@ -83,7 +83,7 @@ function DocumentHeader({
const toc = (
<Tooltip
tooltip={ui.tocVisible ? t("Hide contents") : t("Show contents")}
shortcut="ctrl+alt+h"
shortcut={`ctrl+${metaDisplay}+h`}
delay={250}
placement="bottom"
>
@@ -35,6 +35,7 @@ const DocumentLink = styled(Link)`
const Title = styled.h3`
display: flex;
align-items: center;
max-width: 90%;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
@@ -77,7 +78,7 @@ function ReferenceListItem({
}}
{...rest}
>
<Title dir="auto">
<Title>
{document.emoji ? (
<Emoji>{document.emoji}</Emoji>
) : (
+3 -4
View File
@@ -17,13 +17,12 @@ type Props = {
function DocumentDelete({ document, onSubmit }: Props) {
const { t } = useTranslation();
const { ui, documents, collections } = useStores();
const { ui, documents } = useStores();
const history = useHistory();
const [isDeleting, setDeleting] = React.useState(false);
const [isArchiving, setArchiving] = React.useState(false);
const { showToast } = ui;
const canArchive = !document.isDraft && !document.isArchived;
const collection = collections.get(document.collectionId);
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
@@ -46,7 +45,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
}
// otherwise, redirect to the collection home
history.push(collectionUrl(collection?.url || "/"));
history.push(collectionUrl(document.collectionId));
}
onSubmit();
} catch (err) {
@@ -55,7 +54,7 @@ function DocumentDelete({ document, onSubmit }: Props) {
setDeleting(false);
}
},
[showToast, onSubmit, ui, document, documents, history, collection]
[showToast, onSubmit, ui, document, documents, history]
);
const handleArchive = React.useCallback(
+44 -43
View File
@@ -1,57 +1,58 @@
// @flow
import { observer } from "mobx-react";
import { inject } from "mobx-react";
import queryString from "query-string";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import {
type RouterHistory,
type Location,
type Match,
} from "react-router-dom";
import DocumentsStore from "stores/DocumentsStore";
import UiStore from "stores/UiStore";
import CenteredContent from "components/CenteredContent";
import Flex from "components/Flex";
import LoadingPlaceholder from "components/LoadingPlaceholder";
import useStores from "hooks/useStores";
import { editDocumentUrl } from "utils/routeHelpers";
function DocumentNew() {
const history = useHistory();
const location = useLocation();
const match = useRouteMatch();
const { t } = useTranslation();
const { documents, ui, collections } = useStores();
const id = match.params.id || "";
type Props = {
history: RouterHistory,
location: Location,
documents: DocumentsStore,
ui: UiStore,
match: Match,
};
useEffect(() => {
async function createDocument() {
const params = queryString.parse(location.search);
try {
const collection = await collections.fetch(id);
class DocumentNew extends React.Component<Props> {
async componentDidMount() {
const params = queryString.parse(this.props.location.search);
const document = await documents.create({
collectionId: collection.id,
parentDocumentId: params.parentDocumentId,
templateId: params.templateId,
template: params.template,
title: "",
text: "",
});
history.replace(editDocumentUrl(document));
} catch (err) {
ui.showToast(t("Couldnt create the document, try again?"), {
type: "error",
});
history.goBack();
}
try {
const document = await this.props.documents.create({
collectionId: this.props.match.params.id,
parentDocumentId: params.parentDocumentId,
templateId: params.templateId,
template: params.template,
title: "",
text: "",
});
this.props.history.replace(editDocumentUrl(document));
} catch (err) {
this.props.ui.showToast("Couldnt create the document, try again?", {
type: "error",
});
this.props.history.goBack();
}
createDocument();
});
}
return (
<Flex column auto>
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
</Flex>
);
render() {
return (
<Flex column auto>
<CenteredContent>
<LoadingPlaceholder />
</CenteredContent>
</Flex>
);
}
}
export default observer(DocumentNew);
export default inject("documents", "ui")(DocumentNew);
-60
View File
@@ -1,60 +0,0 @@
// @flow
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import Document from "models/Document.js";
import Button from "components/Button";
import Flex from "components/Flex";
import HelpText from "components/HelpText";
import useStores from "hooks/useStores";
type Props = {|
document: Document,
onSubmit: () => void,
|};
function DocumentPermanentDelete({ document, onSubmit }: Props) {
const [isDeleting, setIsDeleting] = React.useState(false);
const { t } = useTranslation();
const { ui, documents } = useStores();
const { showToast } = ui;
const history = useHistory();
const handleSubmit = React.useCallback(
async (ev: SyntheticEvent<>) => {
ev.preventDefault();
try {
setIsDeleting(true);
await documents.delete(document, { permanent: true });
showToast(t("Document permanently deleted"), { type: "success" });
onSubmit();
history.push("/trash");
} catch (err) {
showToast(err.message, { type: "error" });
} finally {
setIsDeleting(false);
}
},
[document, onSubmit, showToast, t, history, documents]
);
return (
<Flex column>
<form onSubmit={handleSubmit}>
<HelpText>
<Trans
defaults="Are you sure you want to permanently delete the <em>{{ documentTitle }}</em> document? This action is immediate and cannot be undone."
values={{ documentTitle: document.titleWithDefault }}
components={{ em: <strong /> }}
/>
</HelpText>
<Button type="submit" danger>
{isDeleting ? `${t("Deleting")}` : t("Im sure  Delete")}
</Button>
</form>
</Flex>
);
}
export default observer(DocumentPermanentDelete);
+4 -7
View File
@@ -50,13 +50,10 @@ class Drafts extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
});
};
+1 -1
View File
@@ -33,7 +33,7 @@ function KeyboardShortcuts() {
{
shortcut: (
<>
<Key>Ctrl</Key> + <Key>Alt</Key> + <Key>h</Key>
<Key>Ctrl</Key> + <Key>{metaDisplay}</Key> + <Key>h</Key>
</>
),
label: t("Table of contents"),
+7 -10
View File
@@ -6,6 +6,7 @@ import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import ReactDOM from "react-dom";
import { withTranslation, Trans, type TFunction } from "react-i18next";
import keydown from "react-keydown";
import { withRouter, Link } from "react-router-dom";
@@ -102,9 +103,8 @@ class Search extends React.Component<Props> {
if (ev.key === "ArrowDown") {
ev.preventDefault();
if (this.firstDocument) {
if (this.firstDocument instanceof HTMLElement) {
this.firstDocument.focus();
}
const element = ReactDOM.findDOMNode(this.firstDocument);
if (element instanceof HTMLElement) element.focus();
}
}
};
@@ -140,13 +140,10 @@ class Search extends React.Component<Props> {
}) => {
this.props.history.replace({
pathname: this.props.location.pathname,
search: queryString.stringify(
{
...queryString.parse(this.props.location.search),
...search,
},
{ skipEmptyString: true }
),
search: queryString.stringify({
...queryString.parse(this.props.location.search),
...search,
}),
});
};
+13 -20
View File
@@ -4,7 +4,6 @@ import { PlusIcon, GroupIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import GroupNew from "scenes/GroupNew";
import { Action } from "components/Actions";
import Button from "components/Button";
import Empty from "components/Empty";
import GroupListItem from "components/GroupListItem";
@@ -34,31 +33,25 @@ function Groups() {
}, []);
return (
<Scene
title={t("Groups")}
icon={<GroupIcon color="currentColor" />}
actions={
<>
{can.createGroup && (
<Action>
<Button
type="button"
onClick={handleNewGroupModalOpen}
icon={<PlusIcon />}
>
{`${t("New group")}`}
</Button>
</Action>
)}
</>
}
>
<Scene title={t("Groups")} icon={<GroupIcon color="currentColor" />}>
<Heading>{t("Groups")}</Heading>
<HelpText>
<Trans>
Groups can be used to organize and manage the people on your team.
</Trans>
</HelpText>
{can.createGroup && (
<Button
type="button"
onClick={handleNewGroupModalOpen}
icon={<PlusIcon />}
neutral
>
{`${t("New group")}`}
</Button>
)}
<Subheading>{t("All groups")}</Subheading>
<PaginatedList
items={groups.orderedData}
+72 -69
View File
@@ -1,86 +1,89 @@
// @flow
import { observer } from "mobx-react";
import { observable } from "mobx";
import { observer, inject } from "mobx-react";
import { CodeIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import APITokenNew from "scenes/APITokenNew";
import { Action } from "components/Actions";
import ApiKeysStore from "stores/ApiKeysStore";
import UiStore from "stores/UiStore";
import Button from "components/Button";
import Heading from "components/Heading";
import HelpText from "components/HelpText";
import Modal from "components/Modal";
import PaginatedList from "components/PaginatedList";
import Input from "components/Input";
import List from "components/List";
import Scene from "components/Scene";
import Subheading from "components/Subheading";
import TokenListItem from "./components/TokenListItem";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores";
function Tokens() {
const team = useCurrentTeam();
const { t } = useTranslation();
const { apiKeys, policies } = useStores();
const [newModalOpen, setNewModalOpen] = React.useState(false);
const can = policies.abilities(team.id);
type Props = {
apiKeys: ApiKeysStore,
ui: UiStore,
};
const handleNewModalOpen = React.useCallback(() => {
setNewModalOpen(true);
}, []);
@observer
class Tokens extends React.Component<Props> {
@observable name: string = "";
const handleNewModalClose = React.useCallback(() => {
setNewModalOpen(false);
}, []);
componentDidMount() {
this.props.apiKeys.fetchPage({ limit: 100 });
}
return (
<Scene
title={t("API Tokens")}
icon={<CodeIcon color="currentColor" />}
actions={
<>
{can.createApiKey && (
<Action>
<Button
type="submit"
value={`${t("New token")}`}
onClick={handleNewModalOpen}
handleUpdate = (ev: SyntheticInputEvent<*>) => {
this.name = ev.target.value;
};
handleSubmit = async (ev: SyntheticEvent<>) => {
try {
ev.preventDefault();
await this.props.apiKeys.create({ name: this.name });
this.name = "";
} catch (error) {
this.props.ui.showToast(error.message, { type: "error" });
}
};
render() {
const { apiKeys } = this.props;
const hasApiKeys = apiKeys.orderedData.length > 0;
return (
<Scene title="API Tokens" icon={<CodeIcon color="currentColor" />}>
<Heading>API Tokens</Heading>
<HelpText>
You can create an unlimited amount of personal tokens to authenticate
with the API. For more details about the API take a look at the{" "}
<a href="https://www.getoutline.com/developers">
developer documentation
</a>
.
</HelpText>
{hasApiKeys && (
<List>
{apiKeys.orderedData.map((token) => (
<TokenListItem
key={token.id}
token={token}
onDelete={token.delete}
/>
</Action>
)}
</>
}
>
<Heading>{t("API Tokens")}</Heading>
<HelpText>
<Trans
defaults="You can create an unlimited amount of personal tokens to authenticate
with the API. Tokens have the same permissions as your user account.
For more details see the <em>developer documentation</em>."
components={{
em: (
<a href="https://www.getoutline.com/developers" target="_blank" />
),
}}
/>
</HelpText>
<PaginatedList
fetch={apiKeys.fetchPage}
items={apiKeys.orderedData}
heading={<Subheading sticky>{t("Tokens")}</Subheading>}
renderItem={(token) => (
<TokenListItem key={token.id} token={token} onDelete={token.delete} />
))}
</List>
)}
/>
<Modal
title={t("Create a token")}
onRequestClose={handleNewModalClose}
isOpen={newModalOpen}
>
<APITokenNew onSubmit={handleNewModalClose} />
</Modal>
</Scene>
);
<form onSubmit={this.handleSubmit}>
<Input
onChange={this.handleUpdate}
placeholder="Token label (eg. development)"
value={this.name}
required
/>
<Button
type="submit"
value="Create Token"
disabled={apiKeys.isSaving}
/>
</form>
</Scene>
);
}
}
export default observer(Tokens);
export default inject("apiKeys", "ui")(Tokens);
@@ -4,20 +4,17 @@ import ApiKey from "models/ApiKey";
import Button from "components/Button";
import ListItem from "components/List/Item";
type Props = {|
type Props = {
token: ApiKey,
onDelete: (tokenId: string) => Promise<void>,
|};
};
const TokenListItem = ({ token, onDelete }: Props) => {
return (
<ListItem
key={token.id}
title={
<>
{token.name} <code>{token.secret}</code>
</>
}
title={token.name}
subtitle={<code>{token.secret}</code>}
actions={
<Button onClick={() => onDelete(token.id)} neutral>
Revoke
+1 -1
View File
@@ -52,7 +52,7 @@ function UserProfile(props: Props) {
? t("Joined")
: t("Invited")}{" "}
{t("{{ time }} ago.", {
time: formatDistanceToNow(Date.parse(user.createdAt)),
time: formatDistanceToNow(new Date(user.createdAt)),
})}
{user.isAdmin && (
<StyledBadge primary={user.isAdmin}>{t("Admin")}</StyledBadge>
+1 -2
View File
@@ -139,8 +139,7 @@ export default class BaseStore<T: BaseModel> {
throw new Error(`Cannot fetch ${this.modelName}`);
}
const item = this.data.get(id);
let item = this.data.get(id);
if (item && !options.force) return item;
this.isFetching = true;
+1 -29
View File
@@ -1,6 +1,6 @@
// @flow
import invariant from "invariant";
import { concat, find, last } from "lodash";
import { concat, last } from "lodash";
import { computed, action } from "mobx";
import Collection from "models/Collection";
import BaseStore from "./BaseStore";
@@ -126,30 +126,6 @@ export default class CollectionsStore extends BaseStore<Collection> {
return result;
}
@action
async fetch(id: string, options: Object = {}): Promise<*> {
const item = this.get(id) || this.getByUrl(id);
if (item && !options.force) return item;
this.isFetching = true;
try {
const res = await client.post(`/collections.info`, { id });
invariant(res && res.data, "Collection not available");
this.addPolicies(res.policies);
return this.add(res.data);
} catch (err) {
if (err.statusCode === 403) {
this.remove(id);
}
throw err;
} finally {
this.isFetching = false;
}
}
getPathForDocument(documentId: string): ?DocumentPath {
return this.pathsToDocuments.find((path) => path.id === documentId);
}
@@ -159,10 +135,6 @@ export default class CollectionsStore extends BaseStore<Collection> {
if (path) return path.title;
}
getByUrl(url: string): ?Collection {
return find(this.orderedData, (col: Collection) => url.endsWith(col.urlId));
}
delete = async (collection: Collection) => {
await super.delete(collection);
+2 -2
View File
@@ -616,8 +616,8 @@ export default class DocumentsStore extends BaseStore<Document> {
}
@action
async delete(document: Document, options?: {| permanent: boolean |}) {
await super.delete(document, options);
async delete(document: Document) {
await super.delete(document);
// check to see if we have any shares related to this document already
// loaded in local state. If so we can go ahead and remove those too.
+6
View File
@@ -108,9 +108,15 @@ class UiStore {
this.activeCollectionId = collection.id;
};
@action
clearActiveCollection = (): void => {
this.activeCollectionId = undefined;
};
@action
clearActiveDocument = (): void => {
this.activeDocumentId = undefined;
this.activeCollectionId = undefined;
};
@action
-9
View File
@@ -1,9 +0,0 @@
// @flow
/* eslint-disable */
import localStorage from '../../__mocks__/localStorage';
import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";
Enzyme.configure({ adapter: new Adapter() });
global.localStorage = localStorage;
-2
View File
@@ -1,2 +0,0 @@
// @flow
export const runAllPromises = () => new Promise<void>(setImmediate);
+13 -19
View File
@@ -1,6 +1,5 @@
// @flow
import queryString from "query-string";
import Collection from "models/Collection";
import Document from "models/Document";
export function homeUrl(): string {
@@ -12,23 +11,13 @@ export function starredUrl(): string {
}
export function newCollectionUrl(): string {
return "/collection/new";
return "/collections/new";
}
export function collectionUrl(url: string, section: ?string): string {
if (section) return `${url}/${section}`;
return url;
}
export function updateCollectionUrl(
oldUrl: string,
collection: Collection
): string {
// Update url to match the current one
return oldUrl.replace(
new RegExp("/collection/[0-9a-zA-Z-_~]*"),
collection.url
);
export function collectionUrl(collectionId: string, section: ?string): string {
const path = `/collections/${collectionId}`;
if (section) return `${path}/${section}`;
return path;
}
export function documentUrl(doc: Document): string {
@@ -53,9 +42,14 @@ export function documentHistoryUrl(doc: Document, revisionId?: string): string {
* Replace full url's document part with the new one in case
* the document slug has been updated
*/
export function updateDocumentUrl(oldUrl: string, document: Document): string {
export function updateDocumentUrl(oldUrl: string, newUrl: string): string {
// Update url to match the current one
return oldUrl.replace(new RegExp("/doc/[0-9a-zA-Z-_~]*"), document.url);
const urlParts = oldUrl.trim().split("/");
const actions = urlParts.slice(3);
if (actions[0]) {
return [newUrl, actions].join("/");
}
return newUrl;
}
export function newDocumentUrl(
@@ -66,7 +60,7 @@ export function newDocumentUrl(
template?: boolean,
}
): string {
return `/collection/${collectionId}/new?${queryString.stringify(params)}`;
return `/collections/${collectionId}/new?${queryString.stringify(params)}`;
}
export function searchUrl(
+1 -3
View File
@@ -1,12 +1,10 @@
// flow-typed signature: 350413ab85bd03f3d1450c0ae307d106
// flow-typed version: c6154227d1/copy-to-clipboard_v3.x.x/flow_>=v0.104.x
// @flow
declare module "copy-to-clipboard" {
declare module 'copy-to-clipboard' {
declare export type Options = {|
debug?: boolean,
message?: string,
format?: "text/plain" | "text/html",
|};
declare module.exports: (text: string, options?: Options) => boolean;
+50 -30
View File
@@ -13,7 +13,6 @@
"dev": "nodemon --exec \"yarn build:server && yarn build:i18n && node --inspect=0.0.0.0 build/server/index.js\" -e js --ignore build/ --ignore app/",
"lint": "eslint app server shared",
"deploy": "git push heroku master",
"postinstall": "yarn yarn-deduplicate yarn.lock",
"heroku-postbuild": "yarn build && yarn db:migrate",
"sequelize:migrate": "sequelize db:migrate",
"db:create-migration": "sequelize migration:create",
@@ -21,7 +20,7 @@
"db:rollback": "sequelize db:migrate:undo",
"upgrade": "git fetch && git pull && yarn install && yarn heroku-postbuild",
"test": "yarn test:app && yarn test:server",
"test:app": "jest --config=app/.jestconfig.json --runInBand --forceExit",
"test:app": "jest",
"test:server": "jest --config=server/.jestconfig.json --runInBand --forceExit",
"test:watch": "jest --config=server/.jestconfig.json --runInBand --forceExit --watchAll"
},
@@ -29,6 +28,33 @@
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/outline"
},
"jest": {
"testURL": "http://localhost",
"verbose": false,
"roots": [
"app",
"shared"
],
"moduleNameMapper": {
"^shared/(.*)$": "<rootDir>/shared/$1",
"^.*[.](gif|ttf|eot|svg)$": "<rootDir>/__test__/fileMock.js"
},
"moduleFileExtensions": [
"js",
"jsx",
"json"
],
"moduleDirectories": [
"node_modules"
],
"modulePaths": [
"app"
],
"setupFiles": [
"<rootDir>/setupJest.js",
"<rootDir>/__mocks__/window.js"
]
},
"engines": {
"node": ">= 12 <=16"
},
@@ -47,6 +73,7 @@
"@babel/preset-react": "^7.10.4",
"@outlinewiki/koa-passport": "^4.1.4",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
"@rehooks/window-scroll-position": "^1.0.1",
"@sentry/node": "^6.3.1",
"@sentry/react": "^6.3.1",
"@sentry/tracing": "^6.3.1",
@@ -63,9 +90,8 @@
"cancan": "3.1.0",
"chalk": "^4.1.0",
"compressorjs": "^1.0.7",
"copy-to-clipboard": "^3.3.1",
"copy-to-clipboard": "^3.0.6",
"core-js": "^3.10.2",
"datadog-metrics": "^0.9.3",
"date-fns": "2.22.1",
"dd-trace": "^0.32.2",
"debug": "^4.1.1",
@@ -75,7 +101,7 @@
"exports-loader": "^0.6.4",
"fetch-with-proxy": "^3.0.1",
"file-loader": "^1.1.6",
"flow-typed": "^3.3.1",
"flow-typed": "^2.6.2",
"focus-visible": "^5.1.0",
"fractional-index": "^1.0.0",
"fs-extra": "^4.0.2",
@@ -98,7 +124,7 @@
"koa-convert": "1.2.0",
"koa-helmet": "5.2.0",
"koa-jwt": "^3.6.0",
"koa-logger": "^3.2.1",
"koa-logger": "^2.0.1",
"koa-mount": "^3.0.0",
"koa-onerror": "^4.0.0",
"koa-router": "7.0.1",
@@ -107,10 +133,9 @@
"koa-static": "^4.0.1",
"lodash": "^4.17.19",
"mammoth": "^1.4.16",
"mobx": "^4.15.4",
"mobx-react": "^6.3.1",
"mobx": "4.6.0",
"mobx-react": "^6.2.5",
"natural-sort": "^1.0.0",
"node-htmldiff": "^0.9.3",
"nodemailer": "^6.4.16",
"outline-icons": "^1.27.0",
"oy-vey": "^0.10.0",
@@ -120,31 +145,30 @@
"pg": "^8.5.1",
"pg-hstore": "^2.3.3",
"polished": "3.6.5",
"query-string": "^7.0.1",
"query-string": "^4.3.4",
"quoted-printable": "^1.0.1",
"randomstring": "1.1.5",
"raw-loader": "^0.5.1",
"react": "^17.0.2",
"react-autosize-textarea": "^7.1.0",
"react-avatar-editor": "^11.1.0",
"react": "^16.8.6",
"react-autosize-textarea": "^6.0.0",
"react-avatar-editor": "^10.3.0",
"react-color": "^2.17.3",
"react-dnd": "^14.0.1",
"react-dnd-html5-backend": "^14.0.0",
"react-dom": "^17.0.2",
"react-dom": "^16.8.6",
"react-dropzone": "^11.3.2",
"react-helmet": "^6.1.0",
"react-helmet": "^5.2.0",
"react-i18next": "^11.7.3",
"react-is": "^17.0.2",
"react-keydown": "^1.7.3",
"react-portal": "^4.2.0",
"react-portal": "^4.0.0",
"react-router-dom": "^5.2.0",
"react-table": "^7.7.0",
"react-virtualized-auto-sizer": "^1.0.5",
"react-waypoint": "^10.1.0",
"react-virtualized-auto-sizer": "^1.0.2",
"react-waypoint": "^9.0.2",
"react-window": "^1.8.6",
"reakit": "^1.3.8",
"reakit": "^1.3.6",
"regenerator-runtime": "^0.13.7",
"rich-markdown-editor": "^11.13.0",
"rich-markdown-editor": "^11.9.1",
"semver": "^7.3.2",
"sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0",
@@ -153,11 +177,11 @@
"slate-md-serializer": "5.5.4",
"slug": "^4.0.4",
"smooth-scroll-into-view-if-needed": "^1.1.29",
"socket.io": "^2.4.0",
"socket.io": "^2.3.0",
"socket.io-redis": "^5.4.0",
"socketio-auth": "^0.1.1",
"string-replace-to-array": "^1.0.3",
"styled-components": "^5.2.3",
"styled-components": "^5.0.0",
"styled-components-breakpoint": "^2.1.1",
"styled-normalize": "^8.0.4",
"tiny-cookie": "^2.3.1",
@@ -175,8 +199,6 @@
"babel-jest": "^26.2.2",
"babel-loader": "^8.1.0",
"babel-plugin-transform-inline-environment-variables": "^0.4.3",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.6",
"eslint": "^7.6.0",
"eslint-config-react-app": "3.0.6",
"eslint-plugin-flowtype": "^5.2.0",
@@ -194,7 +216,7 @@
"koa-webpack-hot-middleware": "^1.0.3",
"nodemon": "^1.19.4",
"prettier": "^2.0.5",
"react-refresh": "^0.9.0",
"react-refresh": "^0.10.0",
"rimraf": "^2.5.4",
"terser-webpack-plugin": "^4.1.0",
"url-loader": "^0.6.2",
@@ -202,13 +224,11 @@
"webpack-cli": "^3.3.12",
"webpack-manifest-plugin": "^3.0.0",
"webpack-pwa-manifest": "^4.3.0",
"workbox-webpack-plugin": "^6.1.0",
"yarn-deduplicate": "^3.1.0"
"workbox-webpack-plugin": "^6.1.0"
},
"resolutions": {
"prosemirror-view": "1.18.1",
"dot-prop": "^5.2.0",
"js-yaml": "^3.13.1"
},
"version": "0.57.0"
"version": "0.56.0"
}
+1 -1
View File
@@ -7,7 +7,7 @@
],
"setupFiles": [
"<rootDir>/__mocks__/console.js",
"./server/test/setup.js"
"./server/test/helper.js"
],
"testEnvironment": "node"
}
+1 -1
View File
@@ -115,7 +115,7 @@ router.post("collections.create", auth(), async (ctx) => {
router.post("collections.info", auth(), async (ctx) => {
const { id } = ctx.body;
ctx.assertPresent(id, "id is required");
ctx.assertUuid(id, "id is required");
const user = ctx.state.user;
const collection = await Collection.scope({
+2 -2
View File
@@ -284,7 +284,7 @@ describe("#collections.export", () => {
createdById: user.id,
collectionId: collection.id,
userId: user.id,
permission: "read_write",
permission: "read",
});
const res = await server.post("/api/collections.export", {
@@ -305,7 +305,7 @@ describe("#collections.export", () => {
await group.addUser(user, { through: { createdById: user.id } });
await collection.addGroup(group, {
through: { permission: "read_write", createdById: user.id },
through: { permission: "read", createdById: user.id },
});
const res = await server.post("/api/collections.export", {
+13 -43
View File
@@ -5,7 +5,6 @@ import { subtractDate } from "../../shared/utils/date";
import documentCreator from "../commands/documentCreator";
import documentImporter from "../commands/documentImporter";
import documentMover from "../commands/documentMover";
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
import env from "../env";
import {
NotFoundError,
@@ -1175,53 +1174,24 @@ router.post("documents.archive", auth(), async (ctx) => {
});
router.post("documents.delete", auth(), async (ctx) => {
const { id, permanent } = ctx.body;
const { id } = ctx.body;
ctx.assertPresent(id, "id is required");
const user = ctx.state.user;
if (permanent) {
const document = await Document.findByPk(id, {
userId: user.id,
paranoid: false,
});
authorize(user, "permanentDelete", document);
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "delete", document);
await Document.update(
{ parentDocumentId: null },
{
where: {
parentDocumentId: document.id,
},
paranoid: false,
}
);
await document.delete(user.id);
await documentPermanentDeleter([document]);
await Event.create({
name: "documents.permanent_delete",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
} else {
const document = await Document.findByPk(id, { userId: user.id });
authorize(user, "delete", document);
await document.delete(user.id);
await Event.create({
name: "documents.delete",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
}
await Event.create({
name: "documents.delete",
documentId: document.id,
collectionId: document.collectionId,
teamId: document.teamId,
actorId: user.id,
data: { title: document.title },
ip: ctx.request.ip,
});
ctx.body = {
success: true,
+1 -36
View File
@@ -984,21 +984,6 @@ describe("#documents.search", () => {
expect(body.data.length).toEqual(0);
});
it("should not error when search term is very long", async () => {
const { user } = await seed();
const res = await server.post("/api/documents.search", {
body: {
token: user.getJwtToken(),
query:
"much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much much longer search term",
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should return draft documents created by user if chosen", async () => {
const { user } = await seed();
const document = await buildDocument({
@@ -1603,7 +1588,7 @@ describe("#documents.restore", () => {
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.parentDocumentId).toEqual(null);
expect(body.data.parentDocumentId).toEqual(undefined);
expect(body.data.archivedAt).toEqual(null);
});
@@ -2201,26 +2186,6 @@ describe("#documents.delete", () => {
expect(body.success).toEqual(true);
});
it("should allow permanently deleting a document", async () => {
const user = await buildUser();
const document = await buildDocument({
userId: user.id,
teamId: user.teamId,
});
await server.post("/api/documents.delete", {
body: { token: user.getJwtToken(), id: document.id },
});
const res = await server.post("/api/documents.delete", {
body: { token: user.getJwtToken(), id: document.id, permanent: true },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
});
it("should allow deleting document without collection", async () => {
const { user, document, collection } = await seed();
+4 -7
View File
@@ -16,7 +16,6 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
let {
sort = "createdAt",
actorId,
documentId,
collectionId,
direction,
name,
@@ -32,12 +31,10 @@ router.post("events.list", auth(), pagination(), async (ctx) => {
if (actorId) {
ctx.assertUuid(actorId, "actorId must be a UUID");
where = { ...where, actorId };
}
if (documentId) {
ctx.assertUuid(documentId, "documentId must be a UUID");
where = { ...where, documentId };
where = {
...where,
actorId,
};
}
if (collectionId) {
+1 -49
View File
@@ -1,7 +1,7 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import TestServer from "fetch-test-server";
import app from "../app";
import { buildEvent, buildUser } from "../test/factories";
import { buildEvent } from "../test/factories";
import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback());
@@ -101,54 +101,6 @@ describe("#events.list", () => {
expect(body.data[0].id).toEqual(auditEvent.id);
});
it("should allow filtering by documentId", async () => {
const { user, admin, document, collection } = await seed();
const event = await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: admin.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(1);
expect(body.data[0].id).toEqual(event.id);
});
it("should not return events for documentId without authorization", async () => {
const { user, document, collection } = await seed();
const actor = await buildUser();
await buildEvent({
name: "documents.publish",
collectionId: collection.id,
documentId: document.id,
teamId: user.teamId,
actorId: user.id,
});
const res = await server.post("/api/events.list", {
body: {
token: actor.getJwtToken(),
documentId: document.id,
},
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.length).toEqual(0);
});
it("should allow filtering by event name", async () => {
const { user, admin, document, collection } = await seed();
+1 -1
View File
@@ -258,7 +258,7 @@ router.post("users.activate", auth(), async (ctx) => {
router.post("users.invite", auth(), async (ctx) => {
const { invites } = ctx.body;
ctx.assertArray(invites, "invites must be an array");
ctx.assertPresent(invites, "invites is required");
const { user } = ctx.state;
const team = await Team.findByPk(user.teamId);
-11
View File
@@ -167,17 +167,6 @@ describe("#users.invite", () => {
expect(body.data.sent.length).toEqual(1);
});
it("should require invites to be an array", async () => {
const user = await buildUser();
const res = await server.post("/api/users.invite", {
body: {
token: user.getJwtToken(),
invites: { email: "test@example.com", name: "Test", guest: false },
},
});
expect(res.status).toEqual(400);
});
it("should require admin", async () => {
const user = await buildUser();
const res = await server.post("/api/users.invite", {
+51 -6
View File
@@ -2,10 +2,10 @@
import { subDays } from "date-fns";
import debug from "debug";
import Router from "koa-router";
import { documentPermanentDeleter } from "../commands/documentPermanentDeleter";
import { AuthenticationError } from "../errors";
import { Document } from "../models";
import { Op } from "../sequelize";
import { Document, Attachment } from "../models";
import { Op, sequelize } from "../sequelize";
import parseAttachmentIds from "../utils/parseAttachmentIds";
const router = new Router();
const log = debug("utils");
@@ -20,7 +20,7 @@ router.post("utils.gc", async (ctx) => {
log(`Permanently destroying upto ${limit} documents older than 30 days…`);
const documents = await Document.scope("withUnpublished").findAll({
attributes: ["id", "teamId", "text", "deletedAt"],
attributes: ["id", "teamId", "text"],
where: {
deletedAt: {
[Op.lt]: subDays(new Date(), 30),
@@ -30,9 +30,54 @@ router.post("utils.gc", async (ctx) => {
limit,
});
const countDeletedDocument = await documentPermanentDeleter(documents);
const query = `
SELECT COUNT(id)
FROM documents
WHERE "searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"id" != :documentId
`;
log(`Destroyed ${countDeletedDocument} documents`);
for (const document of documents) {
const attachmentIds = parseAttachmentIds(document.text);
for (const attachmentId of attachmentIds) {
const [{ count }] = await sequelize.query(query, {
type: sequelize.QueryTypes.SELECT,
replacements: {
documentId: document.id,
teamId: document.teamId,
query: attachmentId,
},
});
if (parseInt(count) === 0) {
const attachment = await Attachment.findOne({
where: {
teamId: document.teamId,
id: attachmentId,
},
});
if (attachment) {
await attachment.destroy();
log(`Attachment ${attachmentId} deleted`);
} else {
log(`Unknown attachment ${attachmentId} ignored`);
}
}
}
}
await Document.scope("withUnpublished").destroy({
where: {
id: documents.map((document) => document.id),
},
force: true,
});
log(`Destroyed ${documents.length} documents`);
ctx.body = {
success: true,
+90 -2
View File
@@ -2,8 +2,8 @@
import { subDays } from "date-fns";
import TestServer from "fetch-test-server";
import app from "../app";
import { Document } from "../models";
import { buildDocument } from "../test/factories";
import { Attachment, Document } from "../models";
import { buildAttachment, buildDocument } from "../test/factories";
import { flushdb } from "../test/support";
const server = new TestServer(app.callback());
@@ -67,6 +67,94 @@ describe("#utils.gc", () => {
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should destroy attachments no longer referenced", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should handle unknown attachment ids", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
// remove attachment so it no longer exists in the database, this is also
// representative of a corrupt attachment id in the doc or the regex returning
// an incorrect string
await attachment.destroy({ force: true });
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should not destroy attachments referenced in other documents", async () => {
const document1 = await buildDocument();
const document = await buildDocument({
teamId: document1.teamId,
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document1.teamId,
documentId: document.id,
});
document1.text = `![text](${attachment.redirectUrl})`;
await document1.save();
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
expect(await Attachment.count()).toEqual(1);
const res = await server.post("/api/utils.gc", {
body: {
token: process.env.UTILS_SECRET,
},
});
expect(res.status).toEqual(200);
expect(await Attachment.count()).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
});
it("should destroy draft documents deleted more than 30 days ago", async () => {
await buildDocument({
publishedAt: undefined,
+2 -9
View File
@@ -1,6 +1,5 @@
// @flow
import * as Sentry from "@sentry/node";
import debug from "debug";
import Koa from "koa";
import compress from "koa-compress";
import helmet, {
@@ -22,7 +21,6 @@ import updates from "./utils/updates";
const app = new Koa();
const isProduction = process.env.NODE_ENV === "production";
const isTest = process.env.NODE_ENV === "test";
const log = debug("http");
// Construct scripts CSP based on services in use by this installation
const defaultSrc = ["'self'"];
@@ -107,16 +105,11 @@ if (isProduction) {
})
)
);
app.use(logger());
app.use(mount("/emails", emails));
}
// redirect routing logger to optional "http" debug
app.use(
logger((str, args) => {
log(str);
})
);
// catch errors in one place, automatically set status and response headers
onerror(app);
-52
View File
@@ -1,52 +0,0 @@
// @flow
import TestServer from "fetch-test-server";
import app from "./app";
import { buildShare, buildDocument } from "./test/factories";
import { flushdb } from "./test/support";
const server = new TestServer(app.callback());
beforeEach(() => flushdb());
afterAll(() => server.close());
describe("/share/:id", () => {
it("should return standard title in html when loading share", async () => {
const share = await buildShare({ published: false });
const res = await server.get(`/share/${share.id}`);
const body = await res.text();
expect(res.status).toEqual(200);
expect(body).toContain("<title>Outline</title>");
});
it("should return standard title in html when share does not exist", async () => {
const res = await server.get(`/share/junk`);
const body = await res.text();
expect(res.status).toEqual(200);
expect(body).toContain("<title>Outline</title>");
});
it("should return document title in html when loading published share", async () => {
const document = await buildDocument();
const share = await buildShare({ documentId: document.id });
const res = await server.get(`/share/${share.id}`);
const body = await res.text();
expect(res.status).toEqual(200);
expect(body).toContain(`<title>${document.title}</title>`);
});
it("should return document title in html when loading published share with nested doc route", async () => {
const document = await buildDocument();
const share = await buildShare({ documentId: document.id });
const res = await server.get(`/share/${share.id}/doc/test-Cl6g1AgPYn`);
const body = await res.text();
expect(res.status).toEqual(200);
expect(body).toContain(`<title>${document.title}</title>`);
});
});
+6 -46
View File
@@ -2,15 +2,12 @@
import { subMinutes } from "date-fns";
import Router from "koa-router";
import { find } from "lodash";
import { parseDomain, isCustomSubdomain } from "../../../shared/utils/domains";
import { AuthorizationError } from "../../errors";
import mailer, { sendEmail } from "../../mailer";
import errorHandling from "../../middlewares/errorHandling";
import methodOverride from "../../middlewares/methodOverride";
import validation from "../../middlewares/validation";
import { User, Team } from "../../models";
import { signIn } from "../../utils/authentication";
import { isCustomDomain } from "../../utils/domains";
import { getUserForEmailSigninToken } from "../../utils/jwt";
const router = new Router();
@@ -23,56 +20,19 @@ export const config = {
router.use(methodOverride());
router.use(validation());
router.post("email", errorHandling(), async (ctx) => {
router.post("email", async (ctx) => {
const { email } = ctx.body;
ctx.assertEmail(email, "email is required");
const users = await User.scope("withAuthentications").findAll({
const user = await User.scope("withAuthentications").findOne({
where: { email: email.toLowerCase() },
});
if (users.length) {
let team;
if (isCustomDomain(ctx.request.hostname)) {
team = await Team.scope("withAuthenticationProviders").findOne({
where: { domain: ctx.request.hostname },
});
}
if (
process.env.SUBDOMAINS_ENABLED === "true" &&
isCustomSubdomain(ctx.request.hostname) &&
!isCustomDomain(ctx.request.hostname)
) {
const domain = parseDomain(ctx.request.hostname);
const subdomain = domain ? domain.subdomain : undefined;
team = await Team.scope("withAuthenticationProviders").findOne({
where: { subdomain },
});
}
// If there are multiple users with this email address then give precedence
// to the one that is active on this subdomain/domain (if any)
let user = users.find((user) => team && user.teamId === team.id);
// A user was found for the email address, but they don't belong to the team
// that this subdomain belongs to, we load their team and allow the logic to
// continue
if (!user) {
user = users[0];
team = await Team.scope("withAuthenticationProviders").findByPk(
user.teamId
);
}
if (!team) {
team = await Team.scope("withAuthenticationProviders").findByPk(
user.teamId
);
}
if (user) {
const team = await Team.scope("withAuthenticationProviders").findByPk(
user.teamId
);
if (!team) {
ctx.redirect(`/?notice=auth-error`);
return;
-177
View File
@@ -1,177 +0,0 @@
// @flow
import TestServer from "fetch-test-server";
import app from "../../app";
import mailer from "../../mailer";
import { buildUser, buildGuestUser, buildTeam } from "../../test/factories";
import { flushdb } from "../../test/support";
const server = new TestServer(app.callback());
jest.mock("../../mailer");
beforeEach(async () => {
await flushdb();
// $FlowFixMe does not understand Jest mocks
mailer.signin.mockReset();
});
afterAll(() => server.close());
describe("email", () => {
it("should require email param", async () => {
const res = await server.post("/auth/email", {
body: {},
});
const body = await res.json();
expect(res.status).toEqual(400);
expect(body.error).toEqual("validation_error");
expect(body.ok).toEqual(false);
});
it("should respond with redirect location when user is SSO enabled", async () => {
const user = await buildUser();
const res = await server.post("/auth/email", {
body: { email: user.email },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.signin).not.toHaveBeenCalled();
});
it("should respond with redirect location when user is SSO enabled on another subdomain", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const user = await buildUser();
await buildTeam({
subdomain: "example",
});
const res = await server.post("/auth/email", {
body: { email: user.email },
headers: { host: "example.localoutline.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.signin).not.toHaveBeenCalled();
});
it("should respond with success when user is not SSO enabled", async () => {
const user = await buildGuestUser();
const res = await server.post("/auth/email", {
body: { email: user.email },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.signin).toHaveBeenCalled();
});
it("should respond with success regardless of whether successful to prevent crawling email logins", async () => {
const res = await server.post("/auth/email", {
body: { email: "user@example.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.signin).not.toHaveBeenCalled();
});
describe("with multiple users matching email", () => {
it("should default to current subdomain with SSO", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "sso-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildGuestUser({ email });
await buildUser({ email, teamId: team.id });
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "example.localoutline.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.signin).not.toHaveBeenCalled();
});
it("should default to current subdomain with guest email", async () => {
process.env.URL = "http://localoutline.com";
process.env.SUBDOMAINS_ENABLED = "true";
const email = "guest-user@example.org";
const team = await buildTeam({
subdomain: "example",
});
await buildUser({ email });
await buildGuestUser({ email, teamId: team.id });
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "example.localoutline.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.signin).toHaveBeenCalled();
});
it("should default to custom domain with SSO", async () => {
const email = "sso-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildGuestUser({ email });
await buildUser({ email, teamId: team.id });
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "docs.mycompany.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.redirect).toMatch("slack");
expect(mailer.signin).not.toHaveBeenCalled();
});
it("should default to custom domain with guest email", async () => {
const email = "guest-user-2@example.org";
const team = await buildTeam({
domain: "docs.mycompany.com",
});
await buildUser({ email });
await buildGuestUser({ email, teamId: team.id });
const res = await server.post("/auth/email", {
body: { email },
headers: { host: "docs.mycompany.com" },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.success).toEqual(true);
expect(mailer.signin).toHaveBeenCalled();
});
});
});
+2 -2
View File
@@ -16,10 +16,10 @@ jest.mock("aws-sdk", () => {
});
beforeEach(() => {
flushdb();
// $FlowFixMe
sendEmail.mockReset();
return flushdb();
});
describe("accountProvisioner", () => {
@@ -1,64 +0,0 @@
// @flow
import debug from "debug";
import { Document, Attachment } from "../models";
import { sequelize } from "../sequelize";
import parseAttachmentIds from "../utils/parseAttachmentIds";
const log = debug("commands");
export async function documentPermanentDeleter(documents: Document[]) {
const activeDocument = documents.find((doc) => !doc.deletedAt);
if (activeDocument) {
throw new Error(
`Cannot permanently delete ${activeDocument.id} document. Please delete it and try again.`
);
}
const query = `
SELECT COUNT(id)
FROM documents
WHERE "searchVector" @@ to_tsquery('english', :query) AND
"teamId" = :teamId AND
"id" != :documentId
`;
for (const document of documents) {
const attachmentIds = parseAttachmentIds(document.text);
for (const attachmentId of attachmentIds) {
const [{ count }] = await sequelize.query(query, {
type: sequelize.QueryTypes.SELECT,
replacements: {
documentId: document.id,
teamId: document.teamId,
query: attachmentId,
},
});
if (parseInt(count) === 0) {
const attachment = await Attachment.findOne({
where: {
teamId: document.teamId,
id: attachmentId,
},
});
if (attachment) {
await attachment.destroy();
log(`Attachment ${attachmentId} deleted`);
} else {
log(`Unknown attachment ${attachmentId} ignored`);
}
}
}
}
return Document.scope("withUnpublished").destroy({
where: {
id: documents.map((document) => document.id),
},
force: true,
});
}
@@ -1,123 +0,0 @@
// @flow
import { subDays } from "date-fns";
import { Attachment, Document } from "../models";
import { buildAttachment, buildDocument } from "../test/factories";
import { flushdb } from "../test/support";
import { documentPermanentDeleter } from "./documentPermanentDeleter";
jest.mock("aws-sdk", () => {
const mS3 = { deleteObject: jest.fn().mockReturnThis(), promise: jest.fn() };
return {
S3: jest.fn(() => mS3),
Endpoint: jest.fn(),
};
});
beforeEach(() => flushdb());
describe("documentPermanentDeleter", () => {
it("should destroy documents", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should error when trying to destroy undeleted documents", async () => {
const document = await buildDocument({
publishedAt: new Date(),
});
let error;
try {
await documentPermanentDeleter([document]);
} catch (err) {
error = err.message;
}
expect(error).toEqual(
`Cannot permanently delete ${document.id} document. Please delete it and try again.`
);
});
it("should destroy attachments no longer referenced", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should handle unknown attachment ids", async () => {
const document = await buildDocument({
publishedAt: subDays(new Date(), 90),
deletedAt: new Date(),
});
const attachment = await buildAttachment({
teamId: document.teamId,
documentId: document.id,
});
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
// remove attachment so it no longer exists in the database, this is also
// representative of a corrupt attachment id in the doc or the regex returning
// an incorrect string
await attachment.destroy({ force: true });
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(0);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(0);
});
it("should not destroy attachments referenced in other documents", async () => {
const document1 = await buildDocument();
const document = await buildDocument({
teamId: document1.teamId,
publishedAt: subDays(new Date(), 90),
deletedAt: subDays(new Date(), 60),
});
const attachment = await buildAttachment({
teamId: document1.teamId,
documentId: document.id,
});
document1.text = `![text](${attachment.redirectUrl})`;
await document1.save();
document.text = `![text](${attachment.redirectUrl})`;
await document.save();
expect(await Attachment.count()).toEqual(1);
const countDeletedDoc = await documentPermanentDeleter([document]);
expect(countDeletedDoc).toEqual(1);
expect(await Attachment.count()).toEqual(1);
expect(await Document.unscoped().count({ paranoid: false })).toEqual(1);
});
});
+2 -4
View File
@@ -41,13 +41,11 @@ export const CollectionNotificationEmail = ({
<Body>
<Heading>{collection.name}</Heading>
<p>
{actor.name} {eventName} the collection {collection.name}.
{actor.name} {eventName} the collection "{collection.name}".
</p>
<EmptySpace height={10} />
<p>
<Button
href={`${process.env.URL}${collection.url}?ref=notification-email`}
>
<Button href={`${process.env.URL}${collection.url}`}>
Open Collection
</Button>
</p>
+6 -225
View File
@@ -1,10 +1,8 @@
// @flow
import * as React from "react";
import theme from "../../shared/styles/theme";
import { User, Document, Team, Collection } from "../models";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
@@ -17,7 +15,6 @@ export type Props = {
document: Document,
collection: Collection,
eventName: string,
summary: string,
unsubscribeUrl: string,
};
@@ -41,34 +38,26 @@ export const DocumentNotificationEmail = ({
document,
collection,
eventName = "published",
summary,
unsubscribeUrl,
}: Props) => {
const link = `${team.url}${document.url}?ref=notification-email`;
return (
<EmailTemplate>
<Header />
<Body>
<Heading>
{document.title} {eventName}
"{document.title}" {eventName}
</Heading>
<p>
{actor.name} {eventName} the document "{document.title}", in the{" "}
{collection.name} collection.
</p>
{summary && (
<>
<EmptySpace height={20} />
<Diff href={link}>
<div dangerouslySetInnerHTML={{ __html: summary }} />
</Diff>
<EmptySpace height={20} />
</>
)}
<hr />
<EmptySpace height={10} />
<p>{document.getSummary()}</p>
<EmptySpace height={10} />
<p>
<Button href={link}>Open Document</Button>
<Button href={`${team.url}${document.url}`}>Open Document</Button>
</p>
</Body>
@@ -76,211 +65,3 @@ export const DocumentNotificationEmail = ({
</EmailTemplate>
);
};
export const css = `
font-family: ${theme.fontFamily};
font-weight: ${theme.fontWeight};
font-size: 1em;
line-height: 1.7em;
pre {
white-space: pre-wrap;
}
img {
text-align: center;
max-width: 100%;
max-height: 75vh;
clear: both;
}
img.image-right-50 {
float: right;
width: 50%;
margin-left: 2em;
margin-bottom: 1em;
clear: initial;
}
img.image-left-50 {
float: left;
width: 50%;
margin-right: 2em;
margin-bottom: 1em;
clear: initial;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 1em 0 0.5em;
font-weight: 500;
}
.notice {
display: flex;
align-items: center;
background: ${theme.noticeInfoBackground};
color: ${theme.noticeInfoText};
border-radius: 4px;
padding: 8px 16px;
margin: 8px 0;
}
.notice-tip {
background: ${theme.noticeTipBackground};
color: ${theme.noticeTipText};
}
.notice-warning {
background: ${theme.noticeWarningBackground};
color: ${theme.noticeWarningText};
}
b,
strong {
font-weight: 600;
}
p {
margin: 0;
}
a {
color: ${theme.link};
}
ins {
background-color: #128a2929;
text-decoration: none;
}
del {
background-color: ${theme.slateLight};
color: ${theme.slate};
text-decoration: strikethrough;
}
hr {
position: relative;
height: 1em;
border: 0;
}
hr:before {
content: "";
display: block;
position: absolute;
border-top: 1px solid ${theme.horizontalRule};
top: 0.5em;
left: 0;
right: 0;
}
hr.page-break {
page-break-after: always;
}
hr.page-break:before {
border-top: 1px dashed ${theme.horizontalRule};
}
code {
border-radius: 4px;
border: 1px solid ${theme.codeBorder};
padding: 3px 4px;
font-family: ${theme.fontFamilyMono};
font-size: 85%;
}
mark {
border-radius: 1px;
color: ${theme.textHighlightForeground};
background: ${theme.textHighlight};
a {
color: ${theme.textHighlightForeground};
}
}
ul {
padding-left: 0;
}
.checkbox-list-item {
list-style: none;
padding: 4px 0;
margin: 0;
}
.checkbox {
font-size: 0;
display: block;
float: left;
white-space: nowrap;
width: 12px;
height: 12px;
margin-top: 2px;
margin-right: 8px;
border: 1px solid ${theme.textSecondary};
border-radius: 3px;
}
pre {
display: block;
overflow-x: auto;
padding: 0.75em 1em;
line-height: 1.4em;
position: relative;
background: ${theme.codeBackground};
border-radius: 4px;
border: 1px solid ${theme.codeBorder};
-webkit-font-smoothing: initial;
font-family: ${theme.fontFamilyMono};
font-size: 13px;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
margin: 0;
code {
font-size: 13px;
background: none;
padding: 0;
border: 0;
}
}
table {
width: 100%;
border-collapse: collapse;
border-radius: 4px;
margin-top: 1em;
box-sizing: border-box;
* {
box-sizing: border-box;
}
tr {
position: relative;
border-bottom: 1px solid ${theme.tableDivider};
}
td,
th {
position: relative;
vertical-align: top;
border: 1px solid ${theme.tableDivider};
position: relative;
padding: 4px 8px;
min-width: 100px;
}
}
`;
+1 -1
View File
@@ -47,7 +47,7 @@ export const InviteEmail = ({
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}?ref=invite-email`}>Join now</Button>
<Button href={teamUrl}>Join now</Button>
</p>
</Body>
+1 -3
View File
@@ -43,9 +43,7 @@ export const WelcomeEmail = ({ teamUrl }: Props) => {
</p>
<EmptySpace height={10} />
<p>
<Button href={`${teamUrl}/home?ref=welcome-email`}>
View my dashboard
</Button>
<Button href={`${teamUrl}/home`}>View my dashboard</Button>
</p>
</Body>
-25
View File
@@ -1,25 +0,0 @@
// @flow
import * as React from "react";
import theme from "../../../shared/styles/theme";
type Props = {|
children: React.Node,
href?: string,
|};
export default ({ children, ...rest }: Props) => {
const style = {
borderRadius: "4px",
background: theme.secondaryBackground,
padding: ".5em 1em",
color: theme.text,
display: "block",
textDecoration: "none",
};
return (
<a width="100%" style={style} {...rest}>
{children}
</a>
);
};
+2 -2
View File
@@ -3,9 +3,9 @@ import { Table, TBody, TR, TD } from "oy-vey";
import * as React from "react";
import theme from "../../../shared/styles/theme";
type Props = {|
type Props = {
children: React.Node,
|};
};
export default (props: Props) => (
<Table width="550" padding="40">
-3
View File
@@ -35,7 +35,6 @@ export type DocumentEvent =
name: | "documents.create" // eslint-disable-line
| "documents.publish"
| "documents.delete"
| "documents.permanent_delete"
| "documents.pin"
| "documents.unpin"
| "documents.archive"
@@ -100,8 +99,6 @@ export type RevisionEvent = {
documentId: string,
collectionId: string,
teamId: string,
actorId: string,
modelId: string,
};
export type CollectionImportEvent = {
+13 -27
View File
@@ -13,7 +13,6 @@ import {
type Props as DocumentNotificationEmailT,
DocumentNotificationEmail,
documentNotificationEmailText,
css as documentNotificationEmailCSS,
} from "./emails/DocumentNotificationEmail";
import { ExportEmail, exportEmailText } from "./emails/ExportEmail";
import {
@@ -147,9 +146,8 @@ export class Mailer {
this.sendMail({
to: opts.to,
title: `${opts.document.title}${opts.eventName}`,
previewText: `${opts.actor.name} ${opts.eventName} a document`,
previewText: `${opts.actor.name} ${opts.eventName} a new document`,
html: <DocumentNotificationEmail {...opts} />,
headCSS: documentNotificationEmailCSS,
text: documentNotificationEmailText(opts),
});
};
@@ -175,15 +173,8 @@ export class Mailer {
let smtpConfig = {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure:
"SMTP_SECURE" in process.env
? process.env.SMTP_SECURE === "true"
: process.env.NODE_ENV === "production",
secure: process.env.NODE_ENV === "production",
auth: undefined,
tls:
"SMTP_TLS_CIPHERS" in process.env
? { ciphers: process.env.SMTP_TLS_CIPHERS }
: undefined,
};
if (process.env.SMTP_USERNAME) {
@@ -199,24 +190,19 @@ export class Mailer {
if (useTestEmailService) {
log("SMTP_USERNAME not provided, generating test account…");
let testAccount = await nodemailer.createTestAccount();
try {
let testAccount = await nodemailer.createTestAccount();
const smtpConfig = {
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
};
const smtpConfig = {
host: "smtp.ethereal.email",
port: 587,
secure: false,
auth: {
user: testAccount.user,
pass: testAccount.pass,
},
};
this.transporter = nodemailer.createTransport(smtpConfig);
} catch (err) {
log(`Could not generate test account: ${err.message}`);
}
this.transporter = nodemailer.createTransport(smtpConfig);
}
}
}
+2 -33
View File
@@ -9,7 +9,6 @@ import { Document, Collection, View } from "./models";
import policy from "./policies";
import { client, subscriber } from "./redis";
import { getUserForJWT } from "./utils/jwt";
import * as metrics from "./utils/metrics";
import { checkMigrations } from "./utils/startup";
const server = http.createServer(app.callback());
@@ -30,10 +29,6 @@ io.adapter(
})
);
io.origins((_, callback) => {
callback(null, true);
});
io.of("/").adapter.on("error", (err) => {
if (err.name === "MaxRetriesPerRequestError") {
console.error(`Redis error: ${err.message}. Shutting down now.`);
@@ -43,22 +38,6 @@ io.of("/").adapter.on("error", (err) => {
}
});
io.on("connection", (socket) => {
metrics.increment("websockets.connected");
metrics.gaugePerInstance(
"websockets.count",
socket.client.conn.server.clientsCount
);
socket.on("disconnect", () => {
metrics.increment("websockets.disconnected");
metrics.gaugePerInstance(
"websockets.count",
socket.client.conn.server.clientsCount
);
});
});
SocketAuth(io, {
authenticate: async (socket, data, callback) => {
const { token } = data;
@@ -104,9 +83,7 @@ SocketAuth(io, {
}).findByPk(event.collectionId);
if (can(user, "read", collection)) {
socket.join(`collection-${event.collectionId}`, () => {
metrics.increment("websockets.collections.join");
});
socket.join(`collection-${event.collectionId}`);
}
}
@@ -126,8 +103,6 @@ SocketAuth(io, {
);
socket.join(room, () => {
metrics.increment("websockets.documents.join");
// let everyone else in the room know that a new user joined
io.to(room).emit("user.join", {
userId: user.id,
@@ -171,15 +146,11 @@ SocketAuth(io, {
// allow the client to request to leave rooms
socket.on("leave", (event) => {
if (event.collectionId) {
socket.leave(`collection-${event.collectionId}`, () => {
metrics.increment("websockets.collections.leave");
});
socket.leave(`collection-${event.collectionId}`);
}
if (event.documentId) {
const room = `document-${event.documentId}`;
socket.leave(room, () => {
metrics.increment("websockets.documents.leave");
io.to(room).emit("user.leave", {
userId: user.id,
documentId: event.documentId,
@@ -203,8 +174,6 @@ SocketAuth(io, {
});
socket.on("presence", async (event) => {
metrics.increment("websockets.presence");
const room = `document-${event.documentId}`;
if (event.documentId && socket.rooms[room]) {
+1 -9
View File
@@ -9,7 +9,7 @@ export default function createMiddleware(providerName: string) {
return passport.authorize(
providerName,
{ session: false },
async (err, user, result: AccountProvisionerResult) => {
async (err, _, result: AccountProvisionerResult) => {
if (err) {
console.error(err);
@@ -24,14 +24,6 @@ export default function createMiddleware(providerName: string) {
return ctx.redirect(`/?notice=auth-error`);
}
// Passport.js may invoke this callback with err=null and user=null in
// the event that error=access_denied is received from the OAuth server.
// I'm not sure why this exception to the rule exists, but it does:
// https://github.com/jaredhanson/passport-oauth2/blob/e20f26aad60ed54f0e7952928cbb64979ef8da2b/lib/strategy.js#L135
if (!user) {
return ctx.redirect(`/?notice=auth-error`);
}
// Handle errors from Azure which come in the format: message, Trace ID,
// Correlation ID, Timestamp in these two query string parameters.
const { error, error_description } = ctx.request.query;
-7
View File
@@ -1,6 +1,5 @@
// @flow
import { type Context } from "koa";
import { isArrayLike } from "lodash";
import validator from "validator";
import { validateColorHex } from "../../shared/utils/color";
import { validateIndexCharacters } from "../../shared/utils/indexCharacters";
@@ -14,12 +13,6 @@ export default function validation() {
}
};
ctx.assertArray = (value, message) => {
if (!isArrayLike(value)) {
throw new ValidationError(message);
}
};
ctx.assertIn = (value, options, message) => {
if (!options.includes(value)) {
throw new ValidationError(message);
@@ -1,7 +1,15 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.removeConstraint('users', 'users_email_key', {})
await queryInterface.removeConstraint('users', 'users_username_key', {})
await queryInterface.changeColumn('users', 'email', {
type: Sequelize.STRING,
unique: false,
allowNull: false,
});
await queryInterface.changeColumn('users', 'username', {
type: Sequelize.STRING,
unique: false,
allowNull: false,
});
},
down: async (queryInterface, Sequelize) => {
+6 -22
View File
@@ -1,13 +1,13 @@
// @flow
import { find, findIndex, concat, remove, uniq } from "lodash";
import randomstring from "randomstring";
import isUUID from "validator/lib/isUUID";
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
import slug from "slug";
import { Op, DataTypes, sequelize } from "../sequelize";
import slugify from "../utils/slugify";
import CollectionUser from "./CollectionUser";
import Document from "./Document";
slug.defaults.mode = "rfc3986";
const Collection = sequelize.define(
"collection",
{
@@ -72,9 +72,7 @@ const Collection = sequelize.define(
},
getterMethods: {
url() {
if (!this.name) return `/collection/untitled-${this.urlId}`;
return `/collection/${slugify(this.name)}-${this.urlId}`;
return `/collections/${this.id}`;
},
},
}
@@ -225,17 +223,6 @@ Collection.addHook("afterCreate", (model: Collection, options) => {
// Class methods
Collection.findByPk = async function (id, options = {}) {
if (isUUID(id)) {
return this.findOne({ where: { id }, ...options });
} else if (id.match(SLUG_URL_REGEX)) {
return this.findOne({
where: { urlId: id.match(SLUG_URL_REGEX)[1] },
...options,
});
}
};
// get all the membership relationshps a user could have with the collection
Collection.membershipUserIds = async (collectionId: string) => {
const collection = await Collection.scope("withAllMemberships").findByPk(
@@ -395,11 +382,7 @@ Collection.prototype.isChildDocument = function (
let result = false;
const loopChildren = (documents, input) => {
if (result) {
return;
}
documents.forEach((document) => {
return documents.map((document) => {
let parents = [...input];
if (document.id === documentId) {
result = parents.includes(parentDocumentId);
@@ -407,6 +390,7 @@ Collection.prototype.isChildDocument = function (
parents.push(document.id);
loopChildren(document.children, parents);
}
return document;
});
};
+1 -53
View File
@@ -1,5 +1,4 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import randomstring from "randomstring";
import { v4 as uuidv4 } from "uuid";
import { Collection, Document } from "../models";
import {
@@ -10,7 +9,6 @@ import {
buildDocument,
} from "../test/factories";
import { flushdb, seed } from "../test/support";
import slugify from "../utils/slugify";
beforeEach(() => flushdb());
beforeEach(jest.resetAllMocks);
@@ -18,7 +16,7 @@ beforeEach(jest.resetAllMocks);
describe("#url", () => {
test("should return correct url for the collection", () => {
const collection = new Collection({ id: "1234" });
expect(collection.url).toBe(`/collection/untitled-${collection.urlId}`);
expect(collection.url).toBe("/collections/1234");
});
});
@@ -418,53 +416,3 @@ describe("#membershipUserIds", () => {
expect(membershipUserIds.length).toBe(6);
});
});
describe("#findByPk", () => {
test("should return collection with collection Id", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id);
expect(response.id).toBe(collection.id);
});
test("should return collection when urlId is present", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}`;
const response = await Collection.findByPk(id);
expect(response.id).toBe(collection.id);
});
test("should return undefined when incorrect uuid type", async () => {
const collection = await buildCollection();
const response = await Collection.findByPk(collection.id + "-incorrect");
expect(response).toBe(undefined);
});
test("should return undefined when incorrect urlId length", async () => {
const collection = await buildCollection();
const id = `${slugify(collection.name)}-${collection.urlId}incorrect`;
const response = await Collection.findByPk(id);
expect(response).toBe(undefined);
});
test("should return null when no collection is found with uuid", async () => {
const response = await Collection.findByPk(
"a9e71a81-7342-4ea3-9889-9b9cc8f667da"
);
expect(response).toBe(null);
});
test("should return null when no collection is found with urlId", async () => {
const id = `${slugify("test collection")}-${randomstring.generate(15)}`;
const response = await Collection.findByPk(id);
expect(response).toBe(null);
});
});
+4 -4
View File
@@ -7,7 +7,6 @@ import MarkdownSerializer from "slate-md-serializer";
import isUUID from "validator/lib/isUUID";
import { MAX_TITLE_LENGTH } from "../../shared/constants";
import parseTitle from "../../shared/utils/parseTitle";
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
import unescape from "../../shared/utils/unescape";
import { Collection, User } from "../models";
import { DataTypes, sequelize } from "../sequelize";
@@ -15,6 +14,7 @@ import slugify from "../utils/slugify";
import Revision from "./Revision";
const Op = Sequelize.Op;
const URL_REGEX = /^[0-9a-zA-Z-_~]*-([a-zA-Z0-9]{10,15})$/;
const serializer = new MarkdownSerializer();
export const DOCUMENT_VERSION = 2;
@@ -216,10 +216,10 @@ Document.findByPk = async function (id, options = {}) {
where: { id },
...options,
});
} else if (id.match(SLUG_URL_REGEX)) {
} else if (id.match(URL_REGEX)) {
return scope.findOne({
where: {
urlId: id.match(SLUG_URL_REGEX)[1],
urlId: id.match(URL_REGEX)[1],
},
...options,
});
@@ -637,7 +637,7 @@ Document.prototype.unarchive = async function (userId: string) {
},
},
});
if (!parent) this.parentDocumentId = null;
if (!parent) this.parentDocumentId = undefined;
}
if (!this.template) {
+1 -13
View File
@@ -6,8 +6,7 @@ import {
buildTeam,
buildUser,
} from "../test/factories";
import { flushdb, seed } from "../test/support";
import slugify from "../utils/slugify";
import { flushdb } from "../test/support";
beforeEach(() => flushdb());
beforeEach(jest.resetAllMocks);
@@ -308,14 +307,3 @@ describe("#delete", () => {
expect(document.deletedAt).toBeTruthy();
});
});
describe("#findByPk", () => {
test("should return document when urlId is correct", async () => {
const { document } = await seed();
const id = `${slugify(document.title)}-${document.urlId}`;
const response = await Document.findByPk(id);
expect(response.id).toBe(document.id);
});
});
-2
View File
@@ -65,7 +65,6 @@ Event.ACTIVITY_EVENTS = [
"documents.unpin",
"documents.move",
"documents.delete",
"documents.permanent_delete",
"documents.restore",
"users.create",
];
@@ -91,7 +90,6 @@ Event.AUDIT_EVENTS = [
"documents.unpin",
"documents.move",
"documents.delete",
"documents.permanent_delete",
"documents.restore",
"groups.create",
"groups.update",
-3
View File
@@ -15,9 +15,6 @@ const SearchQuery = sequelize.define(
},
query: {
type: DataTypes.STRING,
set(val) {
this.setDataValue("query", val.substring(0, 255));
},
allowNull: false,
},
results: {
+2 -2
View File
@@ -25,7 +25,7 @@ allow(User, "move", Collection, (user, collection) => {
throw new AdminRequiredError();
});
allow(User, "read", Collection, (user, collection) => {
allow(User, ["read", "export"], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (!collection.permission) {
@@ -47,7 +47,7 @@ allow(User, "read", Collection, (user, collection) => {
return true;
});
allow(User, ["share", "export"], Collection, (user, collection) => {
allow(User, "share", Collection, (user, collection) => {
if (user.isViewer) return false;
if (!collection || user.teamId !== collection.teamId) return false;
if (!collection.sharing) return false;
+1 -1
View File
@@ -59,7 +59,7 @@ describe("read permission", () => {
});
const abilities = serialize(user, collection);
expect(abilities.read).toEqual(true);
expect(abilities.export).toEqual(false);
expect(abilities.export).toEqual(true);
expect(abilities.update).toEqual(false);
expect(abilities.share).toEqual(false);
});

Some files were not shown because too many files have changed in this diff Show More