Merge branch 'main' into feat/mass-import

This commit is contained in:
Tom Moor
2021-02-09 20:46:57 -08:00
committed by GitHub
42 changed files with 663 additions and 295 deletions

View File

@ -3,11 +3,15 @@ import * as React from "react";
import styled from "styled-components"; import styled from "styled-components";
import { bounceIn } from "shared/styles/animations"; import { bounceIn } from "shared/styles/animations";
type Props = { type Props = {|
count: number, count: number,
}; |};
const Bubble = ({ count }: Props) => { const Bubble = ({ count }: Props) => {
if (!count) {
return null;
}
return <Count>{count}</Count>; return <Count>{count}</Count>;
}; };

View File

@ -76,7 +76,6 @@ class Layout extends React.Component<Props> {
@keydown("shift+/") @keydown("shift+/")
handleOpenKeyboardShortcuts() { handleOpenKeyboardShortcuts() {
if (this.props.ui.editMode) return;
this.keyboardShortcutsOpen = true; this.keyboardShortcutsOpen = true;
} }
@ -86,7 +85,6 @@ class Layout extends React.Component<Props> {
@keydown(["t", "/", `${meta}+k`]) @keydown(["t", "/", `${meta}+k`])
goToSearch(ev: SyntheticEvent<>) { goToSearch(ev: SyntheticEvent<>) {
if (this.props.ui.editMode) return;
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.redirectTo = searchUrl(); this.redirectTo = searchUrl();
@ -94,7 +92,6 @@ class Layout extends React.Component<Props> {
@keydown("d") @keydown("d")
goToDashboard() { goToDashboard() {
if (this.props.ui.editMode) return;
this.redirectTo = homeUrl(); this.redirectTo = homeUrl();
} }
@ -102,7 +99,7 @@ class Layout extends React.Component<Props> {
const { auth, t, ui } = this.props; const { auth, t, ui } = this.props;
const { user, team } = auth; const { user, team } = auth;
const showSidebar = auth.authenticated && user && team; const showSidebar = auth.authenticated && user && team;
const sidebarCollapsed = ui.editMode || ui.sidebarCollapsed; const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
if (auth.isSuspended) return <ErrorSuspended />; if (auth.isSuspended) return <ErrorSuspended />;
if (this.redirectTo) return <Redirect to={this.redirectTo} push />; if (this.redirectTo) return <Redirect to={this.redirectTo} push />;

View File

@ -16,11 +16,11 @@ import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import CollectionNew from "scenes/CollectionNew"; import CollectionNew from "scenes/CollectionNew";
import Invite from "scenes/Invite"; import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Flex from "components/Flex"; import Flex from "components/Flex";
import Modal from "components/Modal"; import Modal from "components/Modal";
import Scrollable from "components/Scrollable"; import Scrollable from "components/Scrollable";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import Bubble from "./components/Bubble";
import Collections from "./components/Collections"; import Collections from "./components/Collections";
import HeaderBlock from "./components/HeaderBlock"; import HeaderBlock from "./components/HeaderBlock";
import Section from "./components/Section"; import Section from "./components/Section";
@ -118,9 +118,7 @@ function MainSidebar() {
label={ label={
<Drafts align="center"> <Drafts align="center">
{t("Drafts")} {t("Drafts")}
{documents.totalDrafts > 0 && (
<Bubble count={documents.totalDrafts} /> <Bubble count={documents.totalDrafts} />
)}
</Drafts> </Drafts>
} }
active={ active={

View File

@ -3,29 +3,37 @@ import { observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Portal } from "react-portal"; import { Portal } from "react-portal";
import { withRouter } from "react-router-dom"; import { useLocation } from "react-router-dom";
import type { Location } from "react-router-dom";
import styled, { useTheme } from "styled-components"; import styled, { useTheme } from "styled-components";
import breakpoint from "styled-components-breakpoint"; import breakpoint from "styled-components-breakpoint";
import Fade from "components/Fade"; import Fade from "components/Fade";
import Flex from "components/Flex"; import Flex from "components/Flex";
import CollapseToggle, {
Button as CollapseButton,
} from "./components/CollapseToggle";
import ResizeBorder from "./components/ResizeBorder"; import ResizeBorder from "./components/ResizeBorder";
import ResizeHandle from "./components/ResizeHandle"; import Toggle, { ToggleButton, Positioner } from "./components/Toggle";
import usePrevious from "hooks/usePrevious"; import usePrevious from "hooks/usePrevious";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
let firstRender = true; let firstRender = true;
let BOUNCE_ANIMATION_MS = 250; let ANIMATION_MS = 250;
type Props = { type Props = {
children: React.Node, children: React.Node,
location: Location,
}; };
const useResize = ({ width, minWidth, maxWidth, setWidth }) => { function Sidebar({ children }: Props) {
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const width = ui.sidebarWidth;
const collapsed = ui.isEditing || ui.sidebarCollapsed;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0); const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false); const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false); const [isResizing, setResizing] = React.useState(false);
@ -38,24 +46,45 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
// this is simple because the sidebar is always against the left edge // this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth); const width = Math.min(event.pageX - offset, maxWidth);
setWidth(width); const isSmallerThanCollapsePoint = width < minWidth / 2;
},
[offset, maxWidth, setWidth]
);
const handleStopDrag = React.useCallback(() => { if (isSmallerThanCollapsePoint) {
setResizing(false); setWidth(theme.sidebarCollapsedWidth);
if (isSmallerThanMinimum) {
setWidth(minWidth);
setAnimating(true);
} else { } else {
setWidth(width); setWidth(width);
} }
}, [isSmallerThanMinimum, minWidth, width, setWidth]); },
[theme, offset, minWidth, maxWidth, setWidth]
);
const handleStartDrag = React.useCallback( const handleStopDrag = React.useCallback(
(event) => { (event: MouseEvent) => {
setResizing(false);
if (document.activeElement) {
document.activeElement.blur();
}
if (isSmallerThanMinimum) {
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setAnimating(false);
setCollapsing(true);
ui.collapseSidebar();
} else {
setWidth(minWidth);
setAnimating(true);
}
} else {
setWidth(width);
}
},
[ui, isSmallerThanMinimum, minWidth, width, setWidth]
);
const handleMouseDown = React.useCallback(
(event: MouseEvent) => {
setOffset(event.pageX - width); setOffset(event.pageX - width);
setResizing(true); setResizing(true);
setAnimating(false); setAnimating(false);
@ -65,10 +94,19 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
React.useEffect(() => { React.useEffect(() => {
if (isAnimating) { if (isAnimating) {
setTimeout(() => setAnimating(false), BOUNCE_ANIMATION_MS); setTimeout(() => setAnimating(false), ANIMATION_MS);
} }
}, [isAnimating]); }, [isAnimating]);
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
setWidth(minWidth);
setCollapsing(false);
}, ANIMATION_MS);
}
}, [setWidth, minWidth, isCollapsing]);
React.useEffect(() => { React.useEffect(() => {
if (isResizing) { if (isResizing) {
document.addEventListener("mousemove", handleDrag); document.addEventListener("mousemove", handleDrag);
@ -81,32 +119,6 @@ const useResize = ({ width, minWidth, maxWidth, setWidth }) => {
}; };
}, [isResizing, handleDrag, handleStopDrag]); }, [isResizing, handleDrag, handleStopDrag]);
return { isAnimating, isSmallerThanMinimum, isResizing, handleStartDrag };
};
function Sidebar({ location, children }: Props) {
const theme = useTheme();
const { t } = useTranslation();
const { ui } = useStores();
const previousLocation = usePrevious(location);
const width = ui.sidebarWidth;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const collapsed = ui.editMode || ui.sidebarCollapsed;
const {
isAnimating,
isSmallerThanMinimum,
isResizing,
handleStartDrag,
} = useResize({
width,
minWidth,
maxWidth,
setWidth: ui.setSidebarWidth,
});
const handleReset = React.useCallback(() => { const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth); ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]); }, [ui, theme.sidebarWidth]);
@ -124,30 +136,30 @@ function Sidebar({ location, children }: Props) {
const style = React.useMemo( const style = React.useMemo(
() => ({ () => ({
width: `${width}px`, width: `${width}px`,
left:
collapsed && !ui.mobileSidebarVisible
? `${-width + theme.sidebarCollapsedWidth}px`
: 0,
}), }),
[width, collapsed, theme.sidebarCollapsedWidth, ui.mobileSidebarVisible] [width]
);
const toggleStyle = React.useMemo(
() => ({
right: "auto",
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
}),
[width, theme.sidebarCollapsedWidth, collapsed]
); );
const content = ( const content = (
<>
<Container <Container
style={style} style={style}
$sidebarWidth={ui.sidebarWidth} $sidebarWidth={ui.sidebarWidth}
$isCollapsing={isCollapsing}
$isAnimating={isAnimating} $isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum} $isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible} $mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed} $collapsed={collapsed}
column column
> >
{!isResizing && (
<CollapseToggle
collapsed={ui.sidebarCollapsed}
onClick={ui.toggleCollapsedSidebar}
/>
)}
{ui.mobileSidebarVisible && ( {ui.mobileSidebarVisible && (
<Portal> <Portal>
<Fade> <Fade>
@ -155,18 +167,29 @@ function Sidebar({ location, children }: Props) {
</Fade> </Fade>
</Portal> </Portal>
)} )}
{children} {children}
{!ui.sidebarCollapsed && (
<ResizeBorder <ResizeBorder
onMouseDown={handleStartDrag} onMouseDown={handleMouseDown}
onDoubleClick={handleReset} onDoubleClick={ui.sidebarCollapsed ? undefined : handleReset}
$isResizing={isResizing} $isResizing={isResizing}
> />
<ResizeHandle aria-label={t("Resize sidebar")} /> {ui.sidebarCollapsed && !ui.isEditing && (
</ResizeBorder> <Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)} )}
</Container> </Container>
{!ui.isEditing && (
<Toggle
style={toggleStyle}
onClick={ui.toggleCollapsedSidebar}
direction={ui.sidebarCollapsed ? "right" : "left"}
aria-label={ui.sidebarCollapsed ? t("Expand") : t("Collapse")}
/>
)}
</>
); );
// Fade in the sidebar on first render after page load // Fade in the sidebar on first render after page load
@ -195,29 +218,36 @@ const Container = styled(Flex)`
bottom: 0; bottom: 0;
width: 100%; width: 100%;
background: ${(props) => props.theme.sidebarBackground}; background: ${(props) => props.theme.sidebarBackground};
transition: box-shadow, 100ms, ease-in-out, margin-left 100ms ease-out, transition: box-shadow 100ms ease-in-out, transform 100ms ease-out,
left 100ms ease-out,
${(props) => props.theme.backgroundTransition} ${(props) => props.theme.backgroundTransition}
${(props) => ${(props) =>
props.$isAnimating ? `,width ${BOUNCE_ANIMATION_MS}ms ease-out` : ""}; props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
margin-left: ${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}; transform: translateX(
${(props) => (props.$mobileSidebarVisible ? 0 : "-100%")}
);
z-index: ${(props) => props.theme.depths.sidebar}; z-index: ${(props) => props.theme.depths.sidebar};
max-width: 70%; max-width: 70%;
min-width: 280px; min-width: 280px;
${Positioner} {
display: none;
}
@media print { @media print {
display: none; display: none;
left: 0; transform: none;
} }
${breakpoint("tablet")` ${breakpoint("tablet")`
margin: 0; margin: 0;
z-index: 3; z-index: 3;
min-width: 0; min-width: 0;
transform: translateX(${(props) =>
props.$collapsed ? "calc(-100% + 16px)" : 0});
&:hover, &:hover,
&:focus-within { &:focus-within {
left: 0 !important; transform: none;
box-shadow: ${(props) => box-shadow: ${(props) =>
props.$collapsed props.$collapsed
? "rgba(0, 0, 0, 0.2) 1px 0 4px" ? "rgba(0, 0, 0, 0.2) 1px 0 4px"
@ -225,11 +255,11 @@ const Container = styled(Flex)`
? "rgba(0, 0, 0, 0.1) inset -1px 0 2px" ? "rgba(0, 0, 0, 0.1) inset -1px 0 2px"
: "none"}; : "none"};
& ${CollapseButton} { ${Positioner} {
opacity: .75; display: block;
} }
& ${CollapseButton}:hover { ${ToggleButton} {
opacity: 1; opacity: 1;
} }
} }
@ -241,4 +271,4 @@ const Container = styled(Flex)`
`}; `};
`; `;
export default withRouter(observer(Sidebar)); export default observer(Sidebar);

View File

@ -1,59 +0,0 @@
// @flow
import { NextIcon, BackIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Tooltip from "components/Tooltip";
import { meta } from "utils/keyboard";
type Props = {|
collapsed: boolean,
onClick?: (event: SyntheticEvent<>) => void,
|};
function CollapseToggle({ collapsed, ...rest }: Props) {
const { t } = useTranslation();
return (
<Tooltip
tooltip={collapsed ? t("Expand") : t("Collapse")}
shortcut={`${meta}+.`}
delay={500}
placement="bottom"
>
<Button {...rest} tabIndex="-1" aria-hidden>
{collapsed ? (
<NextIcon color="currentColor" />
) : (
<BackIcon color="currentColor" />
)}
</Button>
</Tooltip>
);
}
export const Button = styled.button`
display: block;
position: absolute;
top: 28px;
right: 8px;
border: 0;
width: 24px;
height: 24px;
z-index: 1;
font-weight: 600;
color: ${(props) => props.theme.sidebarText};
background: transparent;
transition: opacity 100ms ease-in-out;
border-radius: 4px;
opacity: 0;
cursor: pointer;
padding: 0;
&:hover {
color: ${(props) => props.theme.white};
background: ${(props) => props.theme.primary};
}
`;
export default CollapseToggle;

View File

@ -42,8 +42,6 @@ class Collections extends React.Component<Props> {
@keydown("n") @keydown("n")
goToNewDocument() { goToNewDocument() {
if (this.props.ui.editMode) return;
const { activeCollectionId } = this.props.ui; const { activeCollectionId } = this.props.ui;
if (!activeCollectionId) return; if (!activeCollectionId) return;

View File

@ -1,6 +1,5 @@
// @flow // @flow
import styled from "styled-components"; import styled from "styled-components";
import ResizeHandle from "./ResizeHandle";
const ResizeBorder = styled.div` const ResizeBorder = styled.div`
position: absolute; position: absolute;
@ -9,20 +8,6 @@ const ResizeBorder = styled.div`
right: -6px; right: -6px;
width: 12px; width: 12px;
cursor: ew-resize; cursor: ew-resize;
${(props) =>
props.$isResizing &&
`
${ResizeHandle} {
opacity: 1;
}
`}
&:hover {
${ResizeHandle} {
opacity: 1;
}
}
`; `;
export default ResizeBorder; export default ResizeBorder;

View File

@ -1,39 +0,0 @@
// @flow
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
const ResizeHandle = styled.button`
opacity: 0;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%);
position: absolute;
top: 50%;
height: 40px;
right: -10px;
width: 8px;
padding: 0;
border: 0;
background: ${(props) => props.theme.sidebarBackground};
border-radius: 8px;
pointer-events: none;
&:after {
content: "";
position: absolute;
top: -24px;
bottom: -24px;
left: -12px;
right: -12px;
}
&:active {
background: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: ew-resize;
`}
`;
export default ResizeHandle;

View File

@ -0,0 +1,75 @@
// @flow
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
direction: "left" | "right",
style?: Object,
onClick?: () => any,
};
const Toggle = React.forwardRef<Props, HTMLButtonElement>(
({ direction = "left", onClick, style }: Props, ref) => {
return (
<Positioner style={style}>
<ToggleButton ref={ref} $direction={direction} onClick={onClick}>
<svg
width="13"
height="30"
viewBox="0 0 13 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill="currentColor"
d="M7.40242 1.48635C8.23085 0.0650039 10.0656 -0.421985 11.5005 0.39863C12.9354 1.21924 13.427 3.03671 12.5986 4.45806L5.59858 16.4681C4.77015 17.8894 2.93538 18.3764 1.5005 17.5558C0.065623 16.7352 -0.426002 14.9177 0.402425 13.4964L7.40242 1.48635Z"
/>
<path
fill="currentColor"
d="M12.5986 25.5419C13.427 26.9633 12.9354 28.7808 11.5005 29.6014C10.0656 30.422 8.23087 29.935 7.40244 28.5136L0.402438 16.5036C-0.425989 15.0823 0.0656365 13.2648 1.50051 12.4442C2.93539 11.6236 4.77016 12.1106 5.59859 13.5319L12.5986 25.5419Z"
/>
</svg>
</ToggleButton>
</Positioner>
);
}
);
export const ToggleButton = styled.button`
opacity: 0;
background: none;
transition: opacity 100ms ease-in-out;
transform: translateY(-50%)
scaleX(${(props) => (props.$direction === "left" ? 1 : -1)});
position: absolute;
top: 50vh;
padding: 8px;
border: 0;
pointer-events: none;
color: ${(props) => props.theme.divider};
&:active {
color: ${(props) => props.theme.sidebarText};
}
${breakpoint("tablet")`
pointer-events: all;
cursor: pointer;
`}
`;
export const Positioner = styled.div`
z-index: 2;
position: absolute;
top: 0;
bottom: 0;
right: -30px;
width: 30px;
&:hover ${ToggleButton}, &:focus-within ${ToggleButton} {
opacity: 1;
}
`;
export default Toggle;

View File

@ -13,17 +13,23 @@ type Props = {|
id?: string, id?: string,
|}; |};
function Switch({ width = 38, height = 20, label, ...props }: Props) { function Switch({ width = 38, height = 20, label, disabled, ...props }: Props) {
const component = ( const component = (
<Wrapper width={width} height={height}> <Wrapper width={width} height={height}>
<HiddenInput type="checkbox" width={width} height={height} {...props} /> <HiddenInput
type="checkbox"
width={width}
height={height}
disabled={disabled}
{...props}
/>
<Slider width={width} height={height} /> <Slider width={width} height={height} />
</Wrapper> </Wrapper>
); );
if (label) { if (label) {
return ( return (
<Label htmlFor={props.id}> <Label disabled={disabled} htmlFor={props.id}>
{component} {component}
<LabelText>{label}</LabelText> <LabelText>{label}</LabelText>
</Label> </Label>
@ -36,6 +42,8 @@ function Switch({ width = 38, height = 20, label, ...props }: Props) {
const Label = styled.label` const Label = styled.label`
display: flex; display: flex;
align-items: center; align-items: center;
${(props) => (props.disabled ? `opacity: 0.75;` : "")}
`; `;
const Wrapper = styled.label` const Wrapper = styled.label`
@ -79,6 +87,11 @@ const HiddenInput = styled.input`
height: 0; height: 0;
visibility: hidden; visibility: hidden;
&:disabled + ${Slider} {
opacity: 0.75;
cursor: default;
}
&:checked + ${Slider} { &:checked + ${Slider} {
background-color: ${(props) => props.theme.primary}; background-color: ${(props) => props.theme.primary};
} }

View File

@ -10,8 +10,8 @@ type Props = {
const StyledNavLink = styled(NavLink)` const StyledNavLink = styled(NavLink)`
position: relative; position: relative;
display: inline-flex;
display: inline-block; align-items: center;
font-weight: 500; font-weight: 500;
font-size: 14px; font-size: 14px;
color: ${(props) => props.theme.textTertiary}; color: ${(props) => props.theme.textTertiary};

View File

@ -8,6 +8,7 @@ const Tabs = styled.nav`
margin-bottom: 12px; margin-bottom: 12px;
overflow-y: auto; overflow-y: auto;
white-space: nowrap; white-space: nowrap;
transition: opacity 250ms ease-out;
`; `;
export const Separator = styled.span` export const Separator = styled.span`

View File

@ -64,6 +64,10 @@ function CollectionMenu({
[history, collection.id] [history, collection.id]
); );
const stopPropagation = React.useCallback((ev: SyntheticEvent<>) => {
ev.stopPropagation();
}, []);
const handleImportDocument = React.useCallback( const handleImportDocument = React.useCallback(
(ev: SyntheticEvent<>) => { (ev: SyntheticEvent<>) => {
ev.preventDefault(); ev.preventDefault();
@ -83,20 +87,19 @@ function CollectionMenu({
try { try {
const file = files[0]; const file = files[0];
const document = await documents.import( const document = await documents.import(file, null, collection.id, {
file, publish: true,
null, });
this.props.collection.id,
{ publish: true }
);
history.push(document.url); history.push(document.url);
} catch (err) { } catch (err) {
ui.showToast(err.message, { ui.showToast(err.message, {
type: "error", type: "error",
}); });
throw err;
} }
}, },
[history, ui, documents] [history, ui, collection.id, documents]
); );
const can = policies.abilities(collection.id); const can = policies.abilities(collection.id);
@ -108,7 +111,7 @@ function CollectionMenu({
type="file" type="file"
ref={file} ref={file}
onChange={handleFilePicked} onChange={handleFilePicked}
onClick={(ev) => ev.stopPropagation()} onClick={stopPropagation}
accept={documents.importFileTypes.join(", ")} accept={documents.importFileTypes.join(", ")}
tabIndex="-1" tabIndex="-1"
/> />
@ -146,7 +149,7 @@ function CollectionMenu({
onClick: () => setShowCollectionEdit(true), onClick: () => setShowCollectionEdit(true),
}, },
{ {
title: `${t("Permissions")}`, title: `${t("Members")}`,
visible: can.update, visible: can.update,
onClick: () => setShowCollectionMembers(true), onClick: () => setShowCollectionMembers(true),
}, },
@ -172,7 +175,7 @@ function CollectionMenu({
{renderModals && ( {renderModals && (
<> <>
<Modal <Modal
title={t("Collection permissions")} title={t("Collection members")}
onRequestClose={() => setShowCollectionMembers(false)} onRequestClose={() => setShowCollectionMembers(false)}
isOpen={showCollectionMembers} isOpen={showCollectionMembers}
> >

View File

@ -15,6 +15,7 @@ import OverflowMenuButton from "components/ContextMenu/OverflowMenuButton";
import Template from "components/ContextMenu/Template"; import Template from "components/ContextMenu/Template";
import Flex from "components/Flex"; import Flex from "components/Flex";
import Modal from "components/Modal"; import Modal from "components/Modal";
import useCurrentTeam from "hooks/useCurrentTeam";
import useStores from "hooks/useStores"; import useStores from "hooks/useStores";
import { import {
documentHistoryUrl, documentHistoryUrl,
@ -49,7 +50,8 @@ function DocumentMenu({
onOpen, onOpen,
onClose, onClose,
}: Props) { }: Props) {
const { policies, collections, auth, ui } = useStores(); const team = useCurrentTeam();
const { policies, collections, ui } = useStores();
const menu = useMenuState({ modal }); const menu = useMenuState({ modal });
const history = useHistory(); const history = useHistory();
const { t } = useTranslation(); const { t } = useTranslation();
@ -130,10 +132,10 @@ function DocumentMenu({
[document] [document]
); );
const can = policies.abilities(document.id);
const canShareDocuments = !!(can.share && auth.team && auth.team.sharing);
const canViewHistory = can.read && !can.restore;
const collection = collections.get(document.collectionId); const collection = collections.get(document.collectionId);
const can = policies.abilities(document.id);
const canShareDocuments = !!(can.share && team.sharing);
const canViewHistory = can.read && !can.restore;
return ( return (
<> <>

View File

@ -16,6 +16,7 @@ export default class Collection extends BaseModel {
icon: string; icon: string;
color: string; color: string;
private: boolean; private: boolean;
sharing: boolean;
documents: NavigationNode[]; documents: NavigationNode[];
createdAt: ?string; createdAt: ?string;
updatedAt: ?string; updatedAt: ?string;
@ -112,6 +113,7 @@ export default class Collection extends BaseModel {
"name", "name",
"color", "color",
"description", "description",
"sharing",
"icon", "icon",
"private", "private",
"sort", "sort",

View File

@ -230,7 +230,7 @@ class CollectionScene extends React.Component<Props> {
)} )}
</Wrapper> </Wrapper>
<Modal <Modal
title={t("Collection permissions")} title={t("Collection members")}
onRequestClose={this.handlePermissionsModalClose} onRequestClose={this.handlePermissionsModalClose}
isOpen={this.permissionsModalOpen} isOpen={this.permissionsModalOpen}
> >

View File

@ -3,6 +3,7 @@ import { observable } from "mobx";
import { inject, observer } from "mobx-react"; import { inject, observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, Trans, type TFunction } from "react-i18next"; import { withTranslation, Trans, type TFunction } from "react-i18next";
import AuthStore from "stores/AuthStore";
import UiStore from "stores/UiStore"; import UiStore from "stores/UiStore";
import Collection from "models/Collection"; import Collection from "models/Collection";
import Button from "components/Button"; import Button from "components/Button";
@ -17,6 +18,7 @@ import Switch from "components/Switch";
type Props = { type Props = {
collection: Collection, collection: Collection,
ui: UiStore, ui: UiStore,
auth: AuthStore,
onSubmit: () => void, onSubmit: () => void,
t: TFunction, t: TFunction,
}; };
@ -24,6 +26,7 @@ type Props = {
@observer @observer
class CollectionEdit extends React.Component<Props> { class CollectionEdit extends React.Component<Props> {
@observable name: string = this.props.collection.name; @observable name: string = this.props.collection.name;
@observable sharing: boolean = this.props.collection.sharing;
@observable description: string = this.props.collection.description; @observable description: string = this.props.collection.description;
@observable icon: string = this.props.collection.icon; @observable icon: string = this.props.collection.icon;
@observable color: string = this.props.collection.color || "#4E5C6E"; @observable color: string = this.props.collection.color || "#4E5C6E";
@ -44,6 +47,7 @@ class CollectionEdit extends React.Component<Props> {
icon: this.icon, icon: this.icon,
color: this.color, color: this.color,
private: this.private, private: this.private,
sharing: this.sharing,
sort: this.sort, sort: this.sort,
}); });
this.props.onSubmit(); this.props.onSubmit();
@ -82,8 +86,13 @@ class CollectionEdit extends React.Component<Props> {
this.private = ev.target.checked; this.private = ev.target.checked;
}; };
handleSharingChange = (ev: SyntheticInputEvent<*>) => {
this.sharing = ev.target.checked;
};
render() { render() {
const { t } = this.props; const { auth, t } = this.props;
const teamSharingEnabled = !!auth.team && auth.team.sharing;
return ( return (
<Flex column> <Flex column>
@ -140,6 +149,25 @@ class CollectionEdit extends React.Component<Props> {
A private collection will only be visible to invited team members. A private collection will only be visible to invited team members.
</Trans> </Trans>
</HelpText> </HelpText>
<Switch
id="sharing"
label={t("Public document sharing")}
onChange={this.handleSharingChange}
checked={this.sharing && teamSharingEnabled}
disabled={!teamSharingEnabled}
/>
<HelpText>
{teamSharingEnabled ? (
<Trans>
When enabled, documents can be shared publicly on the internet.
</Trans>
) : (
<Trans>
Public sharing is currently disabled in the team security
settings.
</Trans>
)}
</HelpText>
<Button <Button
type="submit" type="submit"
disabled={this.isSaving || !this.props.collection.name} disabled={this.isSaving || !this.props.collection.name}
@ -152,4 +180,6 @@ class CollectionEdit extends React.Component<Props> {
} }
} }
export default withTranslation()<CollectionEdit>(inject("ui")(CollectionEdit)); export default withTranslation()<CollectionEdit>(
inject("ui", "auth")(CollectionEdit)
);

View File

@ -3,8 +3,9 @@ import { intersection } from "lodash";
import { observable } from "mobx"; import { observable } from "mobx";
import { inject, observer } from "mobx-react"; import { inject, observer } from "mobx-react";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next"; import { withTranslation, type TFunction, Trans } from "react-i18next";
import { withRouter, type RouterHistory } from "react-router-dom"; import { withRouter, type RouterHistory } from "react-router-dom";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore"; import CollectionsStore from "stores/CollectionsStore";
import UiStore from "stores/UiStore"; import UiStore from "stores/UiStore";
import Collection from "models/Collection"; import Collection from "models/Collection";
@ -18,6 +19,7 @@ import Switch from "components/Switch";
type Props = { type Props = {
history: RouterHistory, history: RouterHistory,
auth: AuthStore,
ui: UiStore, ui: UiStore,
collections: CollectionsStore, collections: CollectionsStore,
onSubmit: () => void, onSubmit: () => void,
@ -30,6 +32,7 @@ class CollectionNew extends React.Component<Props> {
@observable description: string = ""; @observable description: string = "";
@observable icon: string = ""; @observable icon: string = "";
@observable color: string = "#4E5C6E"; @observable color: string = "#4E5C6E";
@observable sharing: boolean = true;
@observable private: boolean = false; @observable private: boolean = false;
@observable isSaving: boolean; @observable isSaving: boolean;
hasOpenedIconPicker: boolean = false; hasOpenedIconPicker: boolean = false;
@ -41,6 +44,7 @@ class CollectionNew extends React.Component<Props> {
{ {
name: this.name, name: this.name,
description: this.description, description: this.description,
sharing: this.sharing,
icon: this.icon, icon: this.icon,
color: this.color, color: this.color,
private: this.private, private: this.private,
@ -59,7 +63,7 @@ class CollectionNew extends React.Component<Props> {
} }
}; };
handleNameChange = (ev: SyntheticInputEvent<*>) => { handleNameChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.name = ev.target.value; this.name = ev.target.value;
// If the user hasn't picked an icon yet, go ahead and suggest one based on // If the user hasn't picked an icon yet, go ahead and suggest one based on
@ -90,24 +94,31 @@ class CollectionNew extends React.Component<Props> {
this.description = getValue(); this.description = getValue();
}; };
handlePrivateChange = (ev: SyntheticInputEvent<*>) => { handlePrivateChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.private = ev.target.checked; this.private = ev.target.checked;
}; };
handleSharingChange = (ev: SyntheticInputEvent<HTMLInputElement>) => {
this.sharing = ev.target.checked;
};
handleChange = (color: string, icon: string) => { handleChange = (color: string, icon: string) => {
this.color = color; this.color = color;
this.icon = icon; this.icon = icon;
}; };
render() { render() {
const { t } = this.props; const { t, auth } = this.props;
const teamSharingEnabled = !!auth.team && auth.team.sharing;
return ( return (
<form onSubmit={this.handleSubmit}> <form onSubmit={this.handleSubmit}>
<HelpText> <HelpText>
{t( <Trans>
"Collections are for grouping your knowledge base. They work best when organized around a topic or internal team — Product or Engineering for example." Collections are for grouping your knowledge base. They work best
)} when organized around a topic or internal team Product or
Engineering for example.
</Trans>
</HelpText> </HelpText>
<Flex> <Flex>
<Input <Input
@ -142,10 +153,25 @@ class CollectionNew extends React.Component<Props> {
checked={this.private} checked={this.private}
/> />
<HelpText> <HelpText>
{t( <Trans>
"A private collection will only be visible to invited team members." A private collection will only be visible to invited team members.
)} </Trans>
</HelpText> </HelpText>
{teamSharingEnabled && (
<>
<Switch
id="sharing"
label={t("Public document sharing")}
onChange={this.handleSharingChange}
checked={this.sharing}
/>
<HelpText>
<Trans>
When enabled, documents can be shared publicly on the internet.
</Trans>
</HelpText>
</>
)}
<Button type="submit" disabled={this.isSaving || !this.name}> <Button type="submit" disabled={this.isSaving || !this.name}>
{this.isSaving ? `${t("Creating")}` : t("Create")} {this.isSaving ? `${t("Creating")}` : t("Create")}
@ -156,5 +182,5 @@ class CollectionNew extends React.Component<Props> {
} }
export default withTranslation()<CollectionNew>( export default withTranslation()<CollectionNew>(
inject("collections", "ui")(withRouter(CollectionNew)) inject("collections", "ui", "auth")(withRouter(CollectionNew))
); );

View File

@ -126,7 +126,7 @@ class Header extends React.Component<Props> {
const isNew = document.isNew; const isNew = document.isNew;
const isTemplate = document.isTemplate; const isTemplate = document.isTemplate;
const can = policies.abilities(document.id); const can = policies.abilities(document.id);
const canShareDocuments = auth.team && auth.team.sharing && can.share; const canShareDocument = auth.team && auth.team.sharing && can.share;
const canToggleEmbeds = auth.team && auth.team.documentEmbeds; const canToggleEmbeds = auth.team && auth.team.documentEmbeds;
const canEdit = can.update && !isEditing; const canEdit = can.update && !isEditing;
@ -200,7 +200,7 @@ class Header extends React.Component<Props> {
<TemplatesMenu document={document} /> <TemplatesMenu document={document} />
</Action> </Action>
)} )}
{!isEditing && canShareDocuments && ( {!isEditing && canShareDocument && (
<Action> <Action>
<Tooltip <Tooltip
tooltip={ tooltip={

View File

@ -4,12 +4,13 @@ import { observable } from "mobx";
import { observer, inject } from "mobx-react"; import { observer, inject } from "mobx-react";
import { PlusIcon } from "outline-icons"; import { PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
import { withTranslation, type TFunction, Trans } from "react-i18next";
import { type Match } from "react-router-dom"; import { type Match } from "react-router-dom";
import AuthStore from "stores/AuthStore"; import AuthStore from "stores/AuthStore";
import PoliciesStore from "stores/PoliciesStore"; import PoliciesStore from "stores/PoliciesStore";
import UsersStore from "stores/UsersStore"; import UsersStore from "stores/UsersStore";
import Invite from "scenes/Invite"; import Invite from "scenes/Invite";
import Bubble from "components/Bubble";
import Button from "components/Button"; import Button from "components/Button";
import CenteredContent from "components/CenteredContent"; import CenteredContent from "components/CenteredContent";
import Empty from "components/Empty"; import Empty from "components/Empty";
@ -27,12 +28,20 @@ type Props = {
users: UsersStore, users: UsersStore,
policies: PoliciesStore, policies: PoliciesStore,
match: Match, match: Match,
t: TFunction,
}; };
@observer @observer
class People extends React.Component<Props> { class People extends React.Component<Props> {
@observable inviteModalOpen: boolean = false; @observable inviteModalOpen: boolean = false;
componentDidMount() {
const { team } = this.props.auth;
if (team) {
this.props.users.fetchCounts(team.id);
}
}
handleInviteModalOpen = () => { handleInviteModalOpen = () => {
this.inviteModalOpen = true; this.inviteModalOpen = true;
}; };
@ -46,7 +55,7 @@ class People extends React.Component<Props> {
}; };
render() { render() {
const { auth, policies, match } = this.props; const { auth, policies, match, t } = this.props;
const { filter } = match.params; const { filter } = match.params;
const currentUser = auth.user; const currentUser = auth.user;
const team = auth.team; const team = auth.team;
@ -65,15 +74,18 @@ class People extends React.Component<Props> {
} }
const can = policies.abilities(team.id); const can = policies.abilities(team.id);
const { counts } = this.props.users;
return ( return (
<CenteredContent> <CenteredContent>
<PageTitle title="People" /> <PageTitle title={t("People")} />
<h1>People</h1> <h1>{t("People")}</h1>
<HelpText> <HelpText>
Everyone that has signed into Outline appears here. Its possible that <Trans>
there are other users who have access through {team.signinMethods} but Everyone that has signed into Outline appears here. Its possible
havent signed in yet. that there are other users who have access through{" "}
{team.signinMethods} but havent signed in yet.
</Trans>
</HelpText> </HelpText>
<Button <Button
type="button" type="button"
@ -84,37 +96,36 @@ class People extends React.Component<Props> {
icon={<PlusIcon />} icon={<PlusIcon />}
neutral neutral
> >
Invite people {t("Invite people")}
</Button> </Button>
<Tabs> <Tabs>
<Tab to="/settings/people" exact> <Tab to="/settings/people" exact>
Active {t("Active")} <Bubble count={counts.active} />
</Tab> </Tab>
<Tab to="/settings/people/admins" exact> <Tab to="/settings/people/admins" exact>
Admins {t("Admins")} <Bubble count={counts.admins} />
</Tab> </Tab>
{can.update && ( {can.update && (
<Tab to="/settings/people/suspended" exact> <Tab to="/settings/people/suspended" exact>
Suspended {t("Suspended")} <Bubble count={counts.suspended} />
</Tab> </Tab>
)} )}
<Tab to="/settings/people/all" exact> <Tab to="/settings/people/all" exact>
Everyone {t("Everyone")} <Bubble count={counts.all - counts.invited} />
</Tab> </Tab>
{can.invite && ( {can.invite && (
<> <>
<Separator /> <Separator />
<Tab to="/settings/people/invited" exact> <Tab to="/settings/people/invited" exact>
Invited {t("Invited")} <Bubble count={counts.invited} />
</Tab> </Tab>
</> </>
)} )}
</Tabs> </Tabs>
<PaginatedList <PaginatedList
items={users} items={users}
empty={<Empty>No people to see here.</Empty>} empty={<Empty>{t("No people to see here.")}</Empty>}
fetch={this.fetchPage} fetch={this.fetchPage}
renderItem={(item) => ( renderItem={(item) => (
<UserListItem <UserListItem
@ -126,7 +137,7 @@ class People extends React.Component<Props> {
/> />
<Modal <Modal
title="Invite people" title={t("Invite people")}
onRequestClose={this.handleInviteModalClose} onRequestClose={this.handleInviteModalClose}
isOpen={this.inviteModalOpen} isOpen={this.inviteModalOpen}
> >
@ -137,4 +148,8 @@ class People extends React.Component<Props> {
} }
} }
export default inject("auth", "users", "policies")(People); export default inject(
"auth",
"users",
"policies"
)(withTranslation()<People>(People));

View File

@ -7,7 +7,7 @@ import BaseModel from "../models/BaseModel";
import type { PaginationParams } from "types"; import type { PaginationParams } from "types";
import { client } from "utils/ApiClient"; import { client } from "utils/ApiClient";
type Action = "list" | "info" | "create" | "update" | "delete"; type Action = "list" | "info" | "create" | "update" | "delete" | "count";
function modelNameFromClassName(string) { function modelNameFromClassName(string) {
return string.charAt(0).toLowerCase() + string.slice(1); return string.charAt(0).toLowerCase() + string.slice(1);
@ -24,7 +24,7 @@ export default class BaseStore<T: BaseModel> {
model: Class<T>; model: Class<T>;
modelName: string; modelName: string;
rootStore: RootStore; rootStore: RootStore;
actions: Action[] = ["list", "info", "create", "update", "delete"]; actions: Action[] = ["list", "info", "create", "update", "delete", "count"];
constructor(rootStore: RootStore, model: Class<T>) { constructor(rootStore: RootStore, model: Class<T>) {
this.rootStore = rootStore; this.rootStore = rootStore;

View File

@ -1,8 +1,7 @@
// @flow // @flow
import invariant from "invariant"; import invariant from "invariant";
import { concat, filter, last } from "lodash"; import { concat, filter, last } from "lodash";
import { action, computed } from "mobx"; import { computed, action } from "mobx";
import naturalSort from "shared/utils/naturalSort"; import naturalSort from "shared/utils/naturalSort";
import Collection from "models/Collection"; import Collection from "models/Collection";
import BaseStore from "./BaseStore"; import BaseStore from "./BaseStore";
@ -104,6 +103,24 @@ export default class CollectionsStore extends BaseStore<Collection> {
return res.data; return res.data;
}; };
async update(params: Object): Promise<Collection> {
const result = await super.update(params);
// If we're changing sharing permissions on the collection then we need to
// remove all locally cached policies for documents in the collection as they
// are now invalid
if (params.sharing !== undefined) {
const collection = this.get(params.id);
if (collection) {
collection.documentIds.forEach((id) => {
this.rootStore.policies.remove(id);
});
}
}
return result;
}
getPathForDocument(documentId: string): ?DocumentPath { getPathForDocument(documentId: string): ?DocumentPath {
return this.pathsToDocuments.find((path) => path.id === documentId); return this.pathsToDocuments.find((path) => path.id === documentId);
} }

View File

@ -22,6 +22,7 @@ export default class DocumentsStore extends BaseStore<Document> {
@observable movingDocumentId: ?string; @observable movingDocumentId: ?string;
importFileTypes: string[] = [ importFileTypes: string[] = [
".md",
"text/markdown", "text/markdown",
"text/plain", "text/plain",
"text/html", "text/html",

View File

@ -21,7 +21,7 @@ class UiStore {
@observable activeDocumentId: ?string; @observable activeDocumentId: ?string;
@observable activeCollectionId: ?string; @observable activeCollectionId: ?string;
@observable progressBarVisible: boolean = false; @observable progressBarVisible: boolean = false;
@observable editMode: boolean = false; @observable isEditing: boolean = false;
@observable tocVisible: boolean = false; @observable tocVisible: boolean = false;
@observable mobileSidebarVisible: boolean = false; @observable mobileSidebarVisible: boolean = false;
@observable sidebarWidth: number; @observable sidebarWidth: number;
@ -151,12 +151,12 @@ class UiStore {
@action @action
enableEditMode = () => { enableEditMode = () => {
this.editMode = true; this.isEditing = true;
}; };
@action @action
disableEditMode = () => { disableEditMode = () => {
this.editMode = false; this.isEditing = false;
}; };
@action @action

View File

@ -1,13 +1,21 @@
// @flow // @flow
import invariant from "invariant"; import invariant from "invariant";
import { filter, orderBy } from "lodash"; import { filter, orderBy } from "lodash";
import { computed, action, runInAction } from "mobx"; import { observable, computed, action, runInAction } from "mobx";
import User from "models/User"; import User from "models/User";
import BaseStore from "./BaseStore"; import BaseStore from "./BaseStore";
import RootStore from "./RootStore"; import RootStore from "./RootStore";
import { client } from "utils/ApiClient"; import { client } from "utils/ApiClient";
export default class UsersStore extends BaseStore<User> { export default class UsersStore extends BaseStore<User> {
@observable counts: {
active: number,
admins: number,
all: number,
invited: number,
suspended: number,
} = {};
constructor(rootStore: RootStore) { constructor(rootStore: RootStore) {
super(rootStore, User); super(rootStore, User);
} }
@ -52,21 +60,25 @@ export default class UsersStore extends BaseStore<User> {
@action @action
promote = (user: User) => { promote = (user: User) => {
this.counts.admins += 1;
return this.actionOnUser("promote", user); return this.actionOnUser("promote", user);
}; };
@action @action
demote = (user: User) => { demote = (user: User) => {
this.counts.admins -= 1;
return this.actionOnUser("demote", user); return this.actionOnUser("demote", user);
}; };
@action @action
suspend = (user: User) => { suspend = (user: User) => {
this.counts.suspended += 1;
return this.actionOnUser("suspend", user); return this.actionOnUser("suspend", user);
}; };
@action @action
activate = (user: User) => { activate = (user: User) => {
this.counts.suspended -= 1;
return this.actionOnUser("activate", user); return this.actionOnUser("activate", user);
}; };
@ -76,10 +88,39 @@ export default class UsersStore extends BaseStore<User> {
invariant(res && res.data, "Data should be available"); invariant(res && res.data, "Data should be available");
runInAction(`invite`, () => { runInAction(`invite`, () => {
res.data.users.forEach(this.add); res.data.users.forEach(this.add);
this.counts.invited += res.data.sent.length;
this.counts.all += res.data.sent.length;
}); });
return res.data; return res.data;
}; };
@action
fetchCounts = async (teamId: string): Promise<*> => {
const res = await client.post(`/users.count`, { teamId });
invariant(res && res.data, "Data should be available");
this.counts = res.data.counts;
return res.data;
};
@action
async delete(user: User, options: Object = {}) {
super.delete(user, options);
if (!user.isSuspended && user.lastActiveAt) {
this.counts.active -= 1;
}
if (user.isInvited) {
this.counts.invited -= 1;
}
if (user.isAdmin) {
this.counts.admins -= 1;
}
if (user.isSuspended) {
this.counts.suspended -= 1;
}
this.counts.all -= 1;
}
notInCollection = (collectionId: string, query: string = "") => { notInCollection = (collectionId: string, query: string = "") => {
const memberships = filter( const memberships = filter(
this.rootStore.memberships.orderedData, this.rootStore.memberships.orderedData,

View File

@ -1,6 +1,7 @@
// @flow // @flow
import invariant from "invariant"; import invariant from "invariant";
import { map, trim } from "lodash"; import { map, trim } from "lodash";
import { getCookie } from "tiny-cookie";
import stores from "stores"; import stores from "stores";
import download from "./download"; import download from "./download";
import { import {
@ -18,6 +19,11 @@ type Options = {
baseUrl?: string, baseUrl?: string,
}; };
// authorization cookie set by a Cloudflare Access proxy
const CF_AUTHORIZATION = getCookie("CF_Authorization");
// if the cookie is set, we must pass it with all ApiClient requests
const CREDENTIALS = CF_AUTHORIZATION ? "same-origin" : "omit";
class ApiClient { class ApiClient {
baseUrl: string; baseUrl: string;
userAgent: string; userAgent: string;
@ -91,7 +97,7 @@ class ApiClient {
body, body,
headers, headers,
redirect: "follow", redirect: "follow",
credentials: "omit", credentials: CREDENTIALS,
cache: "no-cache", cache: "no-cache",
}); });
} catch (err) { } catch (err) {

View File

@ -153,7 +153,7 @@
"react-waypoint": "^9.0.2", "react-waypoint": "^9.0.2",
"react-window": "^1.8.6", "react-window": "^1.8.6",
"reakit": "^1.3.4", "reakit": "^1.3.4",
"rich-markdown-editor": "^11.1.6", "rich-markdown-editor": "^11.2.0-0",
"semver": "^7.3.2", "semver": "^7.3.2",
"sequelize": "^6.3.4", "sequelize": "^6.3.4",
"sequelize-cli": "^6.2.0", "sequelize-cli": "^6.2.0",

View File

@ -35,6 +35,7 @@ router.post("collections.create", auth(), async (ctx) => {
name, name,
color, color,
description, description,
sharing,
icon, icon,
sort = Collection.DEFAULT_SORT, sort = Collection.DEFAULT_SORT,
} = ctx.body; } = ctx.body;
@ -56,6 +57,7 @@ router.post("collections.create", auth(), async (ctx) => {
teamId: user.teamId, teamId: user.teamId,
createdById: user.id, createdById: user.id,
private: isPrivate, private: isPrivate,
sharing,
sort, sort,
}); });
@ -491,7 +493,7 @@ router.post("collections.export_all", auth(), async (ctx) => {
}); });
router.post("collections.update", auth(), async (ctx) => { router.post("collections.update", auth(), async (ctx) => {
let { id, name, description, icon, color, sort } = ctx.body; let { id, name, description, icon, color, sort, sharing } = ctx.body;
const isPrivate = ctx.body.private; const isPrivate = ctx.body.private;
if (color) { if (color) {
@ -537,6 +539,9 @@ router.post("collections.update", auth(), async (ctx) => {
if (isPrivate !== undefined) { if (isPrivate !== undefined) {
collection.private = isPrivate; collection.private = isPrivate;
} }
if (sharing !== undefined) {
collection.sharing = sharing;
}
if (sort !== undefined) { if (sort !== undefined) {
collection.sort = sort; collection.sort = sort;
} }

View File

@ -897,6 +897,18 @@ describe("#collections.create", () => {
expect(body.policies[0].abilities.export).toBeTruthy(); expect(body.policies[0].abilities.export).toBeTruthy();
}); });
it("should allow setting sharing to false", async () => {
const { user } = await seed();
const res = await server.post("/api/collections.create", {
body: { token: user.getJwtToken(), name: "Test", sharing: false },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.id).toBeTruthy();
expect(body.data.sharing).toBe(false);
});
it("should return correct policies with private collection", async () => { it("should return correct policies with private collection", async () => {
const { user } = await seed(); const { user } = await seed();
const res = await server.post("/api/collections.create", { const res = await server.post("/api/collections.create", {

View File

@ -489,6 +489,11 @@ async function loadDocument({ id, shareId, user }) {
authorize(user, "read", document); authorize(user, "read", document);
} }
const collection = await Collection.findByPk(document.collectionId);
if (!collection.sharing) {
throw new AuthorizationError();
}
const team = await Team.findByPk(document.teamId); const team = await Team.findByPk(document.teamId);
if (!team.sharing) { if (!team.sharing) {
throw new AuthorizationError(); throw new AuthorizationError();

View File

@ -112,6 +112,23 @@ describe("#documents.info", () => {
expect(res.status).toEqual(403); expect(res.status).toEqual(403);
}); });
it("should not return document from shareId if sharing is disabled for collection", async () => {
const { document, collection, user } = await seed();
const share = await buildShare({
documentId: document.id,
teamId: document.teamId,
userId: user.id,
});
collection.sharing = false;
await collection.save();
const res = await server.post("/api/documents.info", {
body: { shareId: share.id },
});
expect(res.status).toEqual(403);
});
it("should not return document from revoked shareId", async () => { it("should not return document from revoked shareId", async () => {
const { document, user } = await seed(); const { document, user } = await seed();
const share = await buildShare({ const share = await buildShare({

View File

@ -202,7 +202,7 @@ describe("#shares.create", () => {
expect(body.data.id).toBe(share.id); expect(body.data.id).toBe(share.id);
}); });
it("should not allow creating a share record if disabled", async () => { it("should not allow creating a share record if team sharing disabled", async () => {
const { user, document, team } = await seed(); const { user, document, team } = await seed();
await team.update({ sharing: false }); await team.update({ sharing: false });
const res = await server.post("/api/shares.create", { const res = await server.post("/api/shares.create", {
@ -211,6 +211,15 @@ describe("#shares.create", () => {
expect(res.status).toEqual(403); expect(res.status).toEqual(403);
}); });
it("should not allow creating a share record if collection sharing disabled", async () => {
const { user, collection, document } = await seed();
await collection.update({ sharing: false });
const res = await server.post("/api/shares.create", {
body: { token: user.getJwtToken(), documentId: document.id },
});
expect(res.status).toEqual(403);
});
it("should require authentication", async () => { it("should require authentication", async () => {
const { document } = await seed(); const { document } = await seed();
const res = await server.post("/api/shares.create", { const res = await server.post("/api/shares.create", {

View File

@ -55,6 +55,17 @@ router.post("users.list", auth(), pagination(), async (ctx) => {
}; };
}); });
router.post("users.count", auth(), async (ctx) => {
const { user } = ctx.state;
const counts = await User.getCounts(user.teamId);
ctx.body = {
data: {
counts,
},
};
});
router.post("users.info", auth(), async (ctx) => { router.post("users.info", auth(), async (ctx) => {
ctx.body = { ctx.body = {
data: presentUser(ctx.state.user), data: presentUser(ctx.state.user),

View File

@ -2,7 +2,8 @@
import TestServer from "fetch-test-server"; import TestServer from "fetch-test-server";
import app from "../app"; import app from "../app";
import { buildUser } from "../test/factories"; import { buildTeam, buildUser } from "../test/factories";
import { flushdb, seed } from "../test/support"; import { flushdb, seed } from "../test/support";
const server = new TestServer(app.callback()); const server = new TestServer(app.callback());
@ -353,3 +354,75 @@ describe("#users.activate", () => {
expect(body).toMatchSnapshot(); expect(body).toMatchSnapshot();
}); });
}); });
describe("#users.count", () => {
it("should count active users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count admin users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, isAdmin: true });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(1);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(1);
});
it("should count suspended users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id });
await buildUser({ teamId: team.id, suspendedAt: new Date() });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(2);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(0);
expect(body.data.counts.suspended).toEqual(1);
expect(body.data.counts.active).toEqual(1);
});
it("should count invited users", async () => {
const team = await buildTeam();
const user = await buildUser({ teamId: team.id, lastActiveAt: null });
const res = await server.post("/api/users.count", {
body: { token: user.getJwtToken() },
});
const body = await res.json();
expect(res.status).toEqual(200);
expect(body.data.counts.all).toEqual(1);
expect(body.data.counts.admins).toEqual(0);
expect(body.data.counts.invited).toEqual(1);
expect(body.data.counts.suspended).toEqual(0);
expect(body.data.counts.active).toEqual(0);
});
it("should require authentication", async () => {
const res = await server.post("/api/users.count");
expect(res.status).toEqual(401);
});
});

View File

@ -0,0 +1,12 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.addColumn('collections', 'sharing', {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true
});
},
down: async (queryInterface, Sequelize) => {
await queryInterface.removeColumn('collections', 'sharing');
}
}

View File

@ -24,6 +24,11 @@ const Collection = sequelize.define(
private: DataTypes.BOOLEAN, private: DataTypes.BOOLEAN,
maintainerApprovalRequired: DataTypes.BOOLEAN, maintainerApprovalRequired: DataTypes.BOOLEAN,
documentStructure: DataTypes.JSONB, documentStructure: DataTypes.JSONB,
sharing: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
sort: { sort: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
validate: { validate: {

View File

@ -51,6 +51,9 @@ const User = sequelize.define(
isSuspended() { isSuspended() {
return !!this.suspendedAt; return !!this.suspendedAt;
}, },
isInvited() {
return !this.lastActiveAt;
},
avatarUrl() { avatarUrl() {
const original = this.getDataValue("avatarUrl"); const original = this.getDataValue("avatarUrl");
if (original) { if (original) {
@ -267,4 +270,33 @@ User.afterCreate(async (user, options) => {
]); ]);
}); });
User.getCounts = async function (teamId: string) {
const countSql = `
SELECT
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
COUNT(*) as count
FROM users
WHERE "deletedAt" IS NULL
AND "teamId" = :teamId
`;
const results = await sequelize.query(countSql, {
type: sequelize.QueryTypes.SELECT,
replacements: {
teamId,
},
});
const counts = results[0];
return {
active: parseInt(counts.activeCount),
admins: parseInt(counts.adminCount),
all: parseInt(counts.count),
invited: parseInt(counts.invitedCount),
suspended: parseInt(counts.suspendedCount),
};
};
export default User; export default User;

View File

@ -36,6 +36,29 @@ allow(User, ["read", "export"], Collection, (user, collection) => {
return true; return true;
}); });
allow(User, "share", Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false;
if (!collection.sharing) return false;
if (collection.private) {
invariant(
collection.memberships,
"membership should be preloaded, did you forget withMembership scope?"
);
const allMemberships = concat(
collection.memberships,
collection.collectionGroupMemberships
);
return some(allMemberships, (m) =>
["read_write", "maintainer"].includes(m.permission)
);
}
return true;
});
allow(User, ["publish", "update"], Collection, (user, collection) => { allow(User, ["publish", "update"], Collection, (user, collection) => {
if (!collection || user.teamId !== collection.teamId) return false; if (!collection || user.teamId !== collection.teamId) return false;

View File

@ -31,12 +31,22 @@ allow(User, ["star", "unstar"], Document, (user, document) => {
return user.teamId === document.teamId; return user.teamId === document.teamId;
}); });
allow(User, ["update", "share"], Document, (user, document) => { allow(User, "share", Document, (user, document) => {
if (document.archivedAt) return false; if (document.archivedAt) return false;
if (document.deletedAt) return false; if (document.deletedAt) return false;
// existence of collection option is not required here to account for share tokens if (cannot(user, "share", document.collection)) {
if (document.collection && cannot(user, "update", document.collection)) { return false;
}
return user.teamId === document.teamId;
});
allow(User, "update", Document, (user, document) => {
if (document.archivedAt) return false;
if (document.deletedAt) return false;
if (cannot(user, "update", document.collection)) {
return false; return false;
} }

View File

@ -30,6 +30,7 @@ export default function present(collection: Collection) {
icon: collection.icon, icon: collection.icon,
color: collection.color || "#4E5C6E", color: collection.color || "#4E5C6E",
private: collection.private, private: collection.private,
sharing: collection.sharing,
createdAt: collection.createdAt, createdAt: collection.createdAt,
updatedAt: collection.updatedAt, updatedAt: collection.updatedAt,
deletedAt: collection.deletedAt, deletedAt: collection.deletedAt,

View File

@ -12,7 +12,7 @@
href="/favicon-32.png" href="/favicon-32.png"
sizes="32x32" sizes="32x32"
/> />
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
<link <link
rel="search" rel="search"
type="application/opensearchdescription+xml" type="application/opensearchdescription+xml"

View File

@ -86,8 +86,6 @@
"Change Language": "Change Language", "Change Language": "Change Language",
"Dismiss": "Dismiss", "Dismiss": "Dismiss",
"Keyboard shortcuts": "Keyboard shortcuts", "Keyboard shortcuts": "Keyboard shortcuts",
"Expand": "Expand",
"Collapse": "Collapse",
"New collection": "New collection", "New collection": "New collection",
"Collections": "Collections", "Collections": "Collections",
"Untitled": "Untitled", "Untitled": "Untitled",
@ -110,7 +108,8 @@
"Import / Export": "Import / Export", "Import / Export": "Import / Export",
"Integrations": "Integrations", "Integrations": "Integrations",
"Installation": "Installation", "Installation": "Installation",
"Resize sidebar": "Resize sidebar", "Expand": "Expand",
"Collapse": "Collapse",
"Unstar": "Unstar", "Unstar": "Unstar",
"Star": "Star", "Star": "Star",
"Appearance": "Appearance", "Appearance": "Appearance",
@ -131,10 +130,9 @@
"New document": "New document", "New document": "New document",
"Import document": "Import document", "Import document": "Import document",
"Edit": "Edit", "Edit": "Edit",
"Permissions": "Permissions",
"Export": "Export", "Export": "Export",
"Delete": "Delete", "Delete": "Delete",
"Collection permissions": "Collection permissions", "Collection members": "Collection members",
"Edit collection": "Edit collection", "Edit collection": "Edit collection",
"Delete collection": "Delete collection", "Delete collection": "Delete collection",
"Export collection": "Export collection", "Export collection": "Export collection",
@ -211,6 +209,9 @@
"Alphabetical": "Alphabetical", "Alphabetical": "Alphabetical",
"Private collection": "Private collection", "Private collection": "Private collection",
"A private collection will only be visible to invited team members.": "A private collection will only be visible to invited team members.", "A private collection will only be visible to invited team members.": "A private collection will only be visible to invited team members.",
"Public document sharing": "Public document sharing",
"When enabled, documents can be shared publicly on the internet.": "When enabled, documents can be shared publicly on the internet.",
"Public sharing is currently disabled in the team security settings.": "Public sharing is currently disabled in the team security settings.",
"Saving": "Saving", "Saving": "Saving",
"Save": "Save", "Save": "Save",
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection", "{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
@ -231,6 +232,7 @@
"No people left to add": "No people left to add", "No people left to add": "No people left to add",
"Read only": "Read only", "Read only": "Read only",
"Read & Edit": "Read & Edit", "Read & Edit": "Read & Edit",
"Permissions": "Permissions",
"Active <1></1> ago": "Active <1></1> ago", "Active <1></1> ago": "Active <1></1> ago",
"Never signed in": "Never signed in", "Never signed in": "Never signed in",
"Invited": "Invited", "Invited": "Invited",
@ -319,6 +321,12 @@
"Export Requested": "Export Requested", "Export Requested": "Export Requested",
"Requesting Export": "Requesting Export", "Requesting Export": "Requesting Export",
"Export Data": "Export Data", "Export Data": "Export Data",
"Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.": "Everyone that has signed into Outline appears here. Its possible that there are other users who have access through {team.signinMethods} but havent signed in yet.",
"Active": "Active",
"Admins": "Admins",
"Suspended": "Suspended",
"Everyone": "Everyone",
"No people to see here.": "No people to see here.",
"Profile saved": "Profile saved", "Profile saved": "Profile saved",
"Profile picture updated": "Profile picture updated", "Profile picture updated": "Profile picture updated",
"Unable to upload new profile picture": "Unable to upload new profile picture", "Unable to upload new profile picture": "Unable to upload new profile picture",
@ -336,7 +344,6 @@
"You joined": "You joined", "You joined": "You joined",
"Joined": "Joined", "Joined": "Joined",
"{{ time }} ago.": "{{ time }} ago.", "{{ time }} ago.": "{{ time }} ago.",
"Suspended": "Suspended",
"Edit Profile": "Edit Profile", "Edit Profile": "Edit Profile",
"{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet." "{{ userName }} hasnt updated any documents yet.": "{{ userName }} hasnt updated any documents yet."
} }