feat: Added ability to disable sharing at collection (#1875)
* feat: Added ability to disable sharing at collection * fix: Disable all previous share links when disabling collection share Language * fix: Disable document sharing for read-only collection members * wip * test * fix: Clear policies after updating sharing settings * chore: Less ambiguous language * feat: Allow setting sharing choice on collection creation
This commit is contained in:
@ -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};
|
||||||
}
|
}
|
||||||
|
@ -145,7 +145,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),
|
||||||
},
|
},
|
||||||
@ -171,7 +171,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}
|
||||||
>
|
>
|
||||||
|
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
@ -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",
|
||||||
|
@ -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}
|
||||||
>
|
>
|
||||||
|
@ -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)
|
||||||
|
);
|
||||||
|
@ -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))
|
||||||
);
|
);
|
||||||
|
@ -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={
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
// @flow
|
// @flow
|
||||||
import { concat, filter, last } from "lodash";
|
import { concat, filter, last } from "lodash";
|
||||||
import { 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";
|
||||||
@ -88,6 +88,25 @@ export default class CollectionsStore extends BaseStore<Collection> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
@ -34,6 +34,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;
|
||||||
@ -55,6 +56,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -452,7 +454,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) {
|
||||||
@ -498,6 +500,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;
|
||||||
}
|
}
|
||||||
|
@ -876,6 +876,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", {
|
||||||
|
@ -488,6 +488,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();
|
||||||
|
@ -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({
|
||||||
|
@ -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", {
|
||||||
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
@ -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: {
|
||||||
|
@ -31,6 +31,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;
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -130,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",
|
||||||
@ -210,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",
|
||||||
@ -230,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",
|
||||||
|
Reference in New Issue
Block a user