mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27ab73b0d3 | |||
| c3cd72451d | |||
| 3f8b5b4be9 | |||
| 2251439dec | |||
| 4c22d167bd | |||
| 6732cfca76 | |||
| 431617d4bd | |||
| 01b1ff65ff | |||
| f57b066b25 | |||
| f32a61f193 | |||
| 6cc9b1a109 | |||
| 24a4f12095 | |||
| 590f1481e2 | |||
| af0be5bea6 | |||
| fa9edf5025 | |||
| a74c16fb31 | |||
| fd03582951 | |||
| 80d74b44ad |
@@ -4,12 +4,14 @@ import BaseModel from "./BaseModel";
|
||||
class Group extends BaseModel {
|
||||
id: string;
|
||||
name: string;
|
||||
isPrivate: boolean;
|
||||
memberCount: number;
|
||||
updatedAt: string;
|
||||
|
||||
toJS = () => {
|
||||
return {
|
||||
name: this.name,
|
||||
isPrivate: this.isPrivate,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import CollectionGroupMembershipsStore from "stores/CollectionGroupMembershipsStore";
|
||||
import GroupsStore from "stores/GroupsStore";
|
||||
import ToastsStore from "stores/ToastsStore";
|
||||
import Collection from "models/Collection";
|
||||
import Group from "models/Group";
|
||||
import GroupNew from "scenes/GroupNew";
|
||||
@@ -21,132 +16,123 @@ import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
type Props = {
|
||||
toasts: ToastsStore,
|
||||
auth: AuthStore,
|
||||
collection: Collection,
|
||||
collectionGroupMemberships: CollectionGroupMembershipsStore,
|
||||
groups: GroupsStore,
|
||||
onSubmit: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class AddGroupsToCollection extends React.Component<Props> {
|
||||
@observable newGroupModalOpen: boolean = false;
|
||||
@observable query: string = "";
|
||||
const AddGroupsToCollection = ({ collection, onSubmit }: Props) => {
|
||||
const [newGroupModalOpen, setNewGroupModalOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const { groups, collectionGroupMemberships, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const team = useCurrentTeam();
|
||||
|
||||
handleNewGroupModalOpen = () => {
|
||||
this.newGroupModalOpen = true;
|
||||
};
|
||||
const can = policies.abilities(team.id);
|
||||
const groupsExist = !!groups.orderedData.length;
|
||||
|
||||
handleNewGroupModalClose = () => {
|
||||
this.newGroupModalOpen = false;
|
||||
};
|
||||
const debouncedFetch = React.useMemo(
|
||||
() =>
|
||||
debounce(async (query) => {
|
||||
await groups.fetchPage({
|
||||
query,
|
||||
});
|
||||
}, 250),
|
||||
[groups]
|
||||
);
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<>) => {
|
||||
this.query = ev.target.value;
|
||||
this.debouncedFetch();
|
||||
};
|
||||
|
||||
debouncedFetch = debounce(() => {
|
||||
this.props.groups.fetchPage({
|
||||
query: this.query,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
handleAddGroup = (group: Group) => {
|
||||
const { t } = this.props;
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: SyntheticInputEvent<>) => {
|
||||
setQuery(ev.target.value);
|
||||
debouncedFetch(ev.target.value);
|
||||
},
|
||||
[debouncedFetch]
|
||||
);
|
||||
|
||||
const handleAddGroup = async (group: Group) => {
|
||||
try {
|
||||
this.props.collectionGroupMemberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
await collectionGroupMemberships.create({
|
||||
collectionId: collection.id,
|
||||
groupId: group.id,
|
||||
permission: "read_write",
|
||||
});
|
||||
this.props.toasts.showToast(
|
||||
showToast(
|
||||
t("{{ groupName }} was added to the collection", {
|
||||
groupName: group.name,
|
||||
}),
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (err) {
|
||||
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
|
||||
showToast(t("Could not add group"), { type: "error" });
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { groups, collection, auth, t } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
return (
|
||||
<Flex column>
|
||||
{can.createGroup && (
|
||||
<HelpText>
|
||||
{t("Can’t find the group you’re looking for?")}{" "}
|
||||
<ButtonLink onClick={this.handleNewGroupModalOpen}>
|
||||
<ButtonLink onClick={() => setNewGroupModalOpen(true)}>
|
||||
{t("Create a group")}
|
||||
</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
|
||||
)}
|
||||
{groupsExist && (
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by group name")}…`}
|
||||
value={this.query}
|
||||
onChange={this.handleFilter}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search groups")}
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<PaginatedList
|
||||
empty={
|
||||
this.query ? (
|
||||
<Empty>{t("No groups matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No groups left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={groups.notInCollection(collection.id, this.query)}
|
||||
fetch={this.query ? undefined : groups.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<GroupListItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
showFacepile
|
||||
renderActions={() => (
|
||||
<ButtonWrap>
|
||||
<Button onClick={() => this.handleAddGroup(item)} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
</ButtonWrap>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Create a group")}
|
||||
onRequestClose={this.handleNewGroupModalClose}
|
||||
isOpen={this.newGroupModalOpen}
|
||||
>
|
||||
<GroupNew onSubmit={this.handleNewGroupModalClose} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
)}
|
||||
<PaginatedList
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No groups matching your search")}</Empty>
|
||||
) : groupsExist ? (
|
||||
<Empty>{t("No groups left to add")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No groups found to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={groups.notInCollection(collection.id, query)}
|
||||
fetch={query ? undefined : groups.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<GroupListItem
|
||||
key={item.id}
|
||||
group={item}
|
||||
showFacepile
|
||||
renderActions={() => (
|
||||
<ButtonWrap>
|
||||
<Button onClick={() => handleAddGroup(item)} neutral>
|
||||
{t("Add")}
|
||||
</Button>
|
||||
</ButtonWrap>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Create a group")}
|
||||
onRequestClose={() => setNewGroupModalOpen(false)}
|
||||
isOpen={newGroupModalOpen}
|
||||
>
|
||||
<GroupNew onSubmit={() => setNewGroupModalOpen(false)} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const ButtonWrap = styled.div`
|
||||
margin-left: 6px;
|
||||
`;
|
||||
|
||||
export default withTranslation()<AddGroupsToCollection>(
|
||||
inject(
|
||||
"auth",
|
||||
"groups",
|
||||
"collectionGroupMemberships",
|
||||
"toasts"
|
||||
)(AddGroupsToCollection)
|
||||
);
|
||||
export default observer(AddGroupsToCollection);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
// @flow
|
||||
import { debounce } from "lodash";
|
||||
import { observable } from "mobx";
|
||||
import { inject, observer } from "mobx-react";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { withTranslation, type TFunction } from "react-i18next";
|
||||
import AuthStore from "stores/AuthStore";
|
||||
import MembershipsStore from "stores/MembershipsStore";
|
||||
import ToastsStore from "stores/ToastsStore";
|
||||
import UsersStore from "stores/UsersStore";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Collection from "models/Collection";
|
||||
import User from "models/User";
|
||||
import Invite from "scenes/Invite";
|
||||
@@ -19,116 +14,109 @@ import Input from "components/Input";
|
||||
import Modal from "components/Modal";
|
||||
import PaginatedList from "components/PaginatedList";
|
||||
import MemberListItem from "./components/MemberListItem";
|
||||
import useCurrentTeam from "hooks/useCurrentTeam";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
toasts: ToastsStore,
|
||||
auth: AuthStore,
|
||||
collection: Collection,
|
||||
memberships: MembershipsStore,
|
||||
users: UsersStore,
|
||||
onSubmit: () => void,
|
||||
t: TFunction,
|
||||
};
|
||||
|
||||
@observer
|
||||
class AddPeopleToCollection extends React.Component<Props> {
|
||||
@observable inviteModalOpen: boolean = false;
|
||||
@observable query: string = "";
|
||||
const AddPeopleToCollection = ({ collection, onSubmit }: Props) => {
|
||||
const [inviteModalOpen, setInviteModalOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const team = useCurrentTeam();
|
||||
const { users, memberships, policies } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const can = policies.abilities(team.id);
|
||||
|
||||
handleInviteModalOpen = () => {
|
||||
this.inviteModalOpen = true;
|
||||
};
|
||||
const debouncedFetch = React.useMemo(
|
||||
() =>
|
||||
debounce(async (query) => {
|
||||
await users.fetchPage({
|
||||
query,
|
||||
});
|
||||
}, 250),
|
||||
[users]
|
||||
);
|
||||
|
||||
handleInviteModalClose = () => {
|
||||
this.inviteModalOpen = false;
|
||||
};
|
||||
const handleFilter = React.useCallback(
|
||||
(ev: SyntheticInputEvent<>) => {
|
||||
setQuery(ev.target.value);
|
||||
debouncedFetch(ev.target.value);
|
||||
},
|
||||
[debouncedFetch]
|
||||
);
|
||||
|
||||
handleFilter = (ev: SyntheticInputEvent<>) => {
|
||||
this.query = ev.target.value;
|
||||
this.debouncedFetch();
|
||||
};
|
||||
|
||||
debouncedFetch = debounce(() => {
|
||||
this.props.users.fetchPage({
|
||||
query: this.query,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
handleAddUser = (user: User) => {
|
||||
const { t } = this.props;
|
||||
const handleAddUser = (user: User) => {
|
||||
try {
|
||||
this.props.memberships.create({
|
||||
collectionId: this.props.collection.id,
|
||||
memberships.create({
|
||||
collectionId: collection.id,
|
||||
userId: user.id,
|
||||
permission: "read_write",
|
||||
});
|
||||
this.props.toasts.showToast(
|
||||
showToast(
|
||||
t("{{ userName }} was added to the collection", {
|
||||
userName: user.name,
|
||||
}),
|
||||
{ type: "success" }
|
||||
);
|
||||
} catch (err) {
|
||||
this.props.toasts.showToast(t("Could not add user"), { type: "error" });
|
||||
showToast(t("Could not add user"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { users, collection, auth, t } = this.props;
|
||||
const { user, team } = auth;
|
||||
if (!user || !team) return null;
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
return (
|
||||
<Flex column>
|
||||
{can.inviteUser && (
|
||||
<HelpText>
|
||||
{t("Need to add someone who’s not yet on the team yet?")}{" "}
|
||||
<ButtonLink onClick={this.handleInviteModalOpen}>
|
||||
<ButtonLink onClick={() => setInviteModalOpen(true)}>
|
||||
{t("Invite people to {{ teamName }}", { teamName: team.name })}
|
||||
</ButtonLink>
|
||||
.
|
||||
</HelpText>
|
||||
)}
|
||||
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={this.query}
|
||||
onChange={this.handleFilter}
|
||||
label={t("Search people")}
|
||||
autoFocus
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<PaginatedList
|
||||
empty={
|
||||
this.query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInCollection(collection.id, this.query)}
|
||||
fetch={this.query ? undefined : users.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<MemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => this.handleAddUser(item)}
|
||||
canEdit
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={this.handleInviteModalClose}
|
||||
isOpen={this.inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={this.handleInviteModalClose} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={`${t("Search by name")}…`}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
label={t("Search people")}
|
||||
autoFocus
|
||||
labelHidden
|
||||
flex
|
||||
/>
|
||||
<PaginatedList
|
||||
empty={
|
||||
query ? (
|
||||
<Empty>{t("No people matching your search")}</Empty>
|
||||
) : (
|
||||
<Empty>{t("No people left to add")}</Empty>
|
||||
)
|
||||
}
|
||||
items={users.notInCollection(collection.id, query)}
|
||||
fetch={query ? undefined : users.fetchPage}
|
||||
renderItem={(item) => (
|
||||
<MemberListItem
|
||||
key={item.id}
|
||||
user={item}
|
||||
onAdd={() => handleAddUser(item)}
|
||||
canEdit
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Modal
|
||||
title={t("Invite people")}
|
||||
onRequestClose={() => setInviteModalOpen(false)}
|
||||
isOpen={inviteModalOpen}
|
||||
>
|
||||
<Invite onSubmit={() => setInviteModalOpen(false)} />
|
||||
</Modal>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default withTranslation()<AddPeopleToCollection>(
|
||||
inject("auth", "users", "memberships", "toasts")(AddPeopleToCollection)
|
||||
);
|
||||
export default observer(AddPeopleToCollection);
|
||||
|
||||
+37
-2
@@ -2,11 +2,13 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Group from "models/Group";
|
||||
import Button from "components/Button";
|
||||
import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import Switch from "components/Switch";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
@@ -18,6 +20,7 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState(group.name);
|
||||
const [isPrivate, setIsPrivate] = React.useState(group.isPrivate);
|
||||
const [isSaving, setIsSaving] = React.useState();
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
@@ -26,7 +29,7 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await group.save({ name: name });
|
||||
await group.save({ name, isPrivate });
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
@@ -34,7 +37,7 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[group, onSubmit, showToast, name]
|
||||
[group, isPrivate, name, onSubmit, showToast]
|
||||
);
|
||||
|
||||
const handleNameChange = React.useCallback((ev: SyntheticInputEvent<*>) => {
|
||||
@@ -60,6 +63,21 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
flex
|
||||
/>
|
||||
</Flex>
|
||||
<SwitchWrapper>
|
||||
<Switch
|
||||
id="isPrivate"
|
||||
label={t("Access to group")}
|
||||
onChange={() => setIsPrivate((prev) => !prev)}
|
||||
checked={!isPrivate}
|
||||
/>
|
||||
<SwitchLabel>
|
||||
<SwitchText>
|
||||
{isPrivate
|
||||
? t("Only members present in the group know about the group")
|
||||
: t("Everyone in the team can view the group")}
|
||||
</SwitchText>
|
||||
</SwitchLabel>
|
||||
</SwitchWrapper>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Saving")}…` : t("Save")}
|
||||
@@ -68,4 +86,21 @@ function GroupEdit({ group, onSubmit }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const SwitchWrapper = styled.div`
|
||||
margin: 20px 0;
|
||||
`;
|
||||
|
||||
const SwitchLabel = styled(Flex)`
|
||||
flex-align: center;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const SwitchText = styled(HelpText)`
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
export default observer(GroupEdit);
|
||||
|
||||
+38
-1
@@ -2,6 +2,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import Group from "models/Group";
|
||||
import GroupMembers from "scenes/GroupMembers";
|
||||
import Button from "components/Button";
|
||||
@@ -9,6 +10,7 @@ import Flex from "components/Flex";
|
||||
import HelpText from "components/HelpText";
|
||||
import Input from "components/Input";
|
||||
import Modal from "components/Modal";
|
||||
import Switch from "components/Switch";
|
||||
import useStores from "hooks/useStores";
|
||||
import useToasts from "hooks/useToasts";
|
||||
|
||||
@@ -22,6 +24,7 @@ function GroupNew({ onSubmit }: Props) {
|
||||
const { showToast } = useToasts();
|
||||
const [name, setName] = React.useState();
|
||||
const [isSaving, setIsSaving] = React.useState();
|
||||
const [isPrivate, setIsPrivate] = React.useState(true);
|
||||
const [group, setGroup] = React.useState();
|
||||
|
||||
const handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||
@@ -29,7 +32,8 @@ function GroupNew({ onSubmit }: Props) {
|
||||
setIsSaving(true);
|
||||
const group = new Group(
|
||||
{
|
||||
name: name,
|
||||
name,
|
||||
isPrivate,
|
||||
},
|
||||
groups
|
||||
);
|
||||
@@ -72,6 +76,22 @@ function GroupNew({ onSubmit }: Props) {
|
||||
<Trans>You’ll be able to add people to the group next.</Trans>
|
||||
</HelpText>
|
||||
|
||||
<SwitchWrapper>
|
||||
<Switch
|
||||
id="isPrivate"
|
||||
label={t("Access to group")}
|
||||
onChange={() => setIsPrivate((prev) => !prev)}
|
||||
checked={!isPrivate}
|
||||
/>
|
||||
<SwitchLabel>
|
||||
<SwitchText>
|
||||
{isPrivate
|
||||
? t("Only members present in the group know about the group")
|
||||
: t("Everyone in the team can view the group")}
|
||||
</SwitchText>
|
||||
</SwitchLabel>
|
||||
</SwitchWrapper>
|
||||
|
||||
<Button type="submit" disabled={isSaving || !name}>
|
||||
{isSaving ? `${t("Creating")}…` : t("Continue")}
|
||||
</Button>
|
||||
@@ -87,4 +107,21 @@ function GroupNew({ onSubmit }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const SwitchWrapper = styled.div`
|
||||
margin: 20px 0;
|
||||
`;
|
||||
|
||||
const SwitchLabel = styled(Flex)`
|
||||
flex-align: center;
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const SwitchText = styled(HelpText)`
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
`;
|
||||
|
||||
export default observer(GroupNew);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.addColumn('groups', 'isPrivate', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
});
|
||||
},
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.removeColumn('groups', 'isPrivate');
|
||||
}
|
||||
};
|
||||
@@ -18,6 +18,11 @@ const Group = sequelize.define(
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
},
|
||||
isPrivate: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
@@ -70,6 +75,26 @@ Group.associate = (models) => {
|
||||
],
|
||||
order: [["name", "ASC"]],
|
||||
});
|
||||
Group.addScope("withCollection", (userId) => ({
|
||||
include: [
|
||||
{
|
||||
association: "groupMemberships",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
model: models.CollectionGroup,
|
||||
as: "collectionGroupMemberships",
|
||||
required: false,
|
||||
include: {
|
||||
model: models.Collection.scope({
|
||||
method: ["withMembership", userId],
|
||||
}),
|
||||
as: "collection",
|
||||
},
|
||||
},
|
||||
],
|
||||
order: [["name", "ASC"]],
|
||||
}));
|
||||
};
|
||||
|
||||
// Cascade deletes to group and collection relations
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AdminRequiredError } from "../errors";
|
||||
import { Group, User, Team } from "../models";
|
||||
import policy from "./policy";
|
||||
|
||||
const { allow } = policy;
|
||||
const { allow, can } = policy;
|
||||
|
||||
allow(User, "createGroup", Team, (actor, team) => {
|
||||
if (!team || actor.isViewer || actor.teamId !== team.id) return false;
|
||||
@@ -13,10 +13,19 @@ allow(User, "createGroup", Team, (actor, team) => {
|
||||
|
||||
allow(User, "read", Group, (actor, group) => {
|
||||
if (!group || actor.teamId !== group.teamId) return false;
|
||||
if (actor.isAdmin) return true;
|
||||
if (actor.isAdmin || !group.isPrivate) return true;
|
||||
if (group.groupMemberships.filter((gm) => gm.userId === actor.id).length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
group.collectionGroupMemberships &&
|
||||
group.collectionGroupMemberships.some((membership) =>
|
||||
can(actor, "read", membership.collection)
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export default function present(group: Group) {
|
||||
return {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
isPrivate: group.isPrivate,
|
||||
memberCount: group.groupMemberships.length,
|
||||
createdAt: group.createdAt,
|
||||
updatedAt: group.updatedAt,
|
||||
|
||||
@@ -157,14 +157,18 @@ router.post("collections.add_group", auth(), async (ctx) => {
|
||||
const { id, groupId, permission = "read_write" } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertUuid(groupId, "groupId is required");
|
||||
const user = ctx.state.user;
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", ctx.state.user.id],
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
authorize(ctx.state.user, "update", collection);
|
||||
authorize(user, "update", collection);
|
||||
|
||||
const group = await Group.findByPk(groupId);
|
||||
authorize(ctx.state.user, "read", group);
|
||||
const group = await Group.scope({
|
||||
method: ["withCollection", user.id],
|
||||
}).findByPk(groupId);
|
||||
|
||||
authorize(user, "read", group);
|
||||
|
||||
let membership = await CollectionGroup.findOne({
|
||||
where: {
|
||||
@@ -178,9 +182,9 @@ router.post("collections.add_group", auth(), async (ctx) => {
|
||||
collectionId: id,
|
||||
groupId,
|
||||
permission,
|
||||
createdById: ctx.state.user.id,
|
||||
createdById: user.id,
|
||||
});
|
||||
} else if (permission) {
|
||||
} else {
|
||||
membership.permission = permission;
|
||||
await membership.save();
|
||||
}
|
||||
@@ -189,7 +193,7 @@ router.post("collections.add_group", auth(), async (ctx) => {
|
||||
name: "collections.add_group",
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: ctx.state.user.id,
|
||||
actorId: user.id,
|
||||
data: { name: group.name, groupId },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
@@ -207,14 +211,17 @@ router.post("collections.remove_group", auth(), async (ctx) => {
|
||||
const { id, groupId } = ctx.body;
|
||||
ctx.assertUuid(id, "id is required");
|
||||
ctx.assertUuid(groupId, "groupId is required");
|
||||
const user = ctx.state.user;
|
||||
|
||||
const collection = await Collection.scope({
|
||||
method: ["withMembership", ctx.state.user.id],
|
||||
method: ["withMembership", user.id],
|
||||
}).findByPk(id);
|
||||
authorize(ctx.state.user, "update", collection);
|
||||
authorize(user, "update", collection);
|
||||
|
||||
const group = await Group.findByPk(groupId);
|
||||
authorize(ctx.state.user, "read", group);
|
||||
const group = await Group.scope({
|
||||
method: ["withCollection", user.id],
|
||||
}).findByPk(groupId);
|
||||
authorize(user, "read", group);
|
||||
|
||||
await collection.removeGroup(group);
|
||||
|
||||
@@ -222,7 +229,7 @@ router.post("collections.remove_group", auth(), async (ctx) => {
|
||||
name: "collections.remove_group",
|
||||
collectionId: collection.id,
|
||||
teamId: collection.teamId,
|
||||
actorId: ctx.state.user.id,
|
||||
actorId: user.id,
|
||||
data: { name: group.name, groupId },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ router.post("groups.list", auth(), pagination(), async (ctx) => {
|
||||
if (!user.isAdmin) {
|
||||
groups = groups.filter(
|
||||
(group) =>
|
||||
!group.isPrivate ||
|
||||
group.groupMemberships.filter((gm) => gm.userId === user.id).length
|
||||
);
|
||||
}
|
||||
@@ -62,7 +63,10 @@ router.post("groups.info", auth(), async (ctx) => {
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const group = await Group.findByPk(id);
|
||||
const group = await Group.scope({
|
||||
method: ["withCollection", user.id],
|
||||
}).findByPk(id);
|
||||
|
||||
authorize(user, "read", group);
|
||||
|
||||
ctx.body = {
|
||||
@@ -72,14 +76,13 @@ router.post("groups.info", auth(), async (ctx) => {
|
||||
});
|
||||
|
||||
router.post("groups.create", auth(), async (ctx) => {
|
||||
const { name } = ctx.body;
|
||||
const { name, isPrivate } = ctx.body;
|
||||
ctx.assertPresent(name, "name is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
|
||||
authorize(user, "createGroup", user.team);
|
||||
let group = await Group.create({
|
||||
name,
|
||||
isPrivate: isPrivate ?? true,
|
||||
teamId: user.teamId,
|
||||
createdById: user.id,
|
||||
});
|
||||
@@ -92,7 +95,7 @@ router.post("groups.create", auth(), async (ctx) => {
|
||||
actorId: user.id,
|
||||
teamId: user.teamId,
|
||||
modelId: group.id,
|
||||
data: { name: group.name },
|
||||
data: { name: group.name, isPrivate: group.isPrivate },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
@@ -103,8 +106,9 @@ router.post("groups.create", auth(), async (ctx) => {
|
||||
});
|
||||
|
||||
router.post("groups.update", auth(), async (ctx) => {
|
||||
const { id, name } = ctx.body;
|
||||
const { id, name, isPrivate } = ctx.body;
|
||||
ctx.assertPresent(name, "name is required");
|
||||
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
@@ -114,6 +118,10 @@ router.post("groups.update", auth(), async (ctx) => {
|
||||
|
||||
group.name = name;
|
||||
|
||||
if (isPrivate !== undefined) {
|
||||
group.isPrivate = !!isPrivate;
|
||||
}
|
||||
|
||||
if (group.changed()) {
|
||||
await group.save();
|
||||
await Event.create({
|
||||
@@ -121,7 +129,7 @@ router.post("groups.update", auth(), async (ctx) => {
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
modelId: group.id,
|
||||
data: { name },
|
||||
data: { name, isPrivate },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
}
|
||||
@@ -147,7 +155,7 @@ router.post("groups.delete", auth(), async (ctx) => {
|
||||
actorId: user.id,
|
||||
modelId: group.id,
|
||||
teamId: group.teamId,
|
||||
data: { name: group.name },
|
||||
data: { name: group.name, isPrivate: group.isPrivate },
|
||||
ip: ctx.request.ip,
|
||||
});
|
||||
|
||||
@@ -161,7 +169,10 @@ router.post("groups.memberships", auth(), pagination(), async (ctx) => {
|
||||
ctx.assertUuid(id, "id is required");
|
||||
|
||||
const user = ctx.state.user;
|
||||
const group = await Group.findByPk(id);
|
||||
const group = await Group.scope({
|
||||
method: ["withCollection", user.id],
|
||||
}).findByPk(id);
|
||||
|
||||
authorize(user, "read", group);
|
||||
|
||||
let userWhere;
|
||||
|
||||
@@ -23,6 +23,7 @@ describe("#groups.create", () => {
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toEqual(name);
|
||||
expect(body.data.isPrivate).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,16 +59,21 @@ describe("#groups.update", () => {
|
||||
});
|
||||
|
||||
describe("when user is admin", () => {
|
||||
let user, group;
|
||||
let admin, group;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await buildAdmin();
|
||||
group = await buildGroup({ teamId: user.teamId });
|
||||
admin = await buildAdmin();
|
||||
group = await buildGroup({ teamId: admin.teamId });
|
||||
});
|
||||
|
||||
it("allows admin to edit a group", async () => {
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: { token: user.getJwtToken(), id: group.id, name: "Test" },
|
||||
body: {
|
||||
token: admin.getJwtToken(),
|
||||
id: group.id,
|
||||
name: "Test",
|
||||
isPrivate: false,
|
||||
},
|
||||
});
|
||||
|
||||
const events = await Event.findAll();
|
||||
@@ -76,11 +82,12 @@ describe("#groups.update", () => {
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.name).toBe("Test");
|
||||
expect(body.data.isPrivate).toBe(false);
|
||||
});
|
||||
|
||||
it("does not create an event if the update is a noop", async () => {
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: { token: user.getJwtToken(), id: group.id, name: group.name },
|
||||
body: { token: admin.getJwtToken(), id: group.id, name: group.name },
|
||||
});
|
||||
|
||||
const events = await Event.findAll();
|
||||
@@ -93,13 +100,13 @@ describe("#groups.update", () => {
|
||||
|
||||
it("fails with validation error when name already taken", async () => {
|
||||
await buildGroup({
|
||||
teamId: user.teamId,
|
||||
teamId: admin.teamId,
|
||||
name: "test",
|
||||
});
|
||||
|
||||
const res = await server.post("/api/groups.update", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
token: admin.getJwtToken(),
|
||||
id: group.id,
|
||||
name: "TEST",
|
||||
},
|
||||
@@ -145,6 +152,48 @@ describe("#groups.list", () => {
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return groups with memberships if isPrivate is false", async () => {
|
||||
const user = await buildUser();
|
||||
const admin = await buildAdmin({ teamId: user.teamId });
|
||||
const group = await buildGroup({ teamId: user.teamId, isPrivate: false });
|
||||
|
||||
await group.addUser(admin, { through: { createdById: admin.id } });
|
||||
|
||||
const res = await server.post("/api/groups.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data["groups"].length).toEqual(1);
|
||||
expect(body.data["groups"][0].id).toEqual(group.id);
|
||||
|
||||
expect(body.data["groupMemberships"].length).toEqual(1);
|
||||
expect(body.data["groupMemberships"][0].groupId).toEqual(group.id);
|
||||
expect(body.data["groupMemberships"][0].user.id).toEqual(admin.id);
|
||||
|
||||
expect(body.policies.length).toEqual(1);
|
||||
expect(body.policies[0].abilities.read).toEqual(true);
|
||||
});
|
||||
|
||||
it("should not return groups with memberships to non-member, if isPrivate is true", async () => {
|
||||
const user = await buildUser();
|
||||
await buildGroup({ teamId: user.teamId });
|
||||
|
||||
const res = await server.post("/api/groups.list", {
|
||||
body: { token: user.getJwtToken() },
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
expect(body.data["groups"].length).toEqual(0);
|
||||
expect(body.data["groupMemberships"].length).toEqual(0);
|
||||
|
||||
expect(body.policies.length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should return groups when membership user is deleted", async () => {
|
||||
const me = await buildUser();
|
||||
const user = await buildUser({ teamId: me.teamId });
|
||||
@@ -188,6 +237,20 @@ describe("#groups.info", () => {
|
||||
expect(body.data.id).toEqual(group.id);
|
||||
});
|
||||
|
||||
it("should return group info to non-member, if group isPrivate is false", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup({ teamId: user.teamId, isPrivate: false });
|
||||
|
||||
const res = await server.post("/api/groups.info", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.id).toEqual(group.id);
|
||||
});
|
||||
|
||||
it("should return group if member", async () => {
|
||||
const user = await buildUser();
|
||||
const group = await buildGroup({ teamId: user.teamId });
|
||||
@@ -297,6 +360,26 @@ describe("#groups.memberships", () => {
|
||||
expect(body.data.groupMemberships[0].user.id).toEqual(user.id);
|
||||
});
|
||||
|
||||
it("should return members in a group to non-member, if isPrivate is false", async () => {
|
||||
const user = await buildUser();
|
||||
const admin = await buildAdmin({ teamId: user.teamId });
|
||||
const group = await buildGroup({ teamId: admin.teamId, isPrivate: false });
|
||||
|
||||
await group.addUser(admin, { through: { createdById: admin.id } });
|
||||
|
||||
const res = await server.post("/api/groups.memberships", {
|
||||
body: { token: user.getJwtToken(), id: group.id },
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.users.length).toEqual(1);
|
||||
expect(body.data.users[0].id).toEqual(admin.id);
|
||||
expect(body.data.groupMemberships.length).toEqual(1);
|
||||
expect(body.data.groupMemberships[0].user.id).toEqual(admin.id);
|
||||
});
|
||||
|
||||
it("should allow filtering members in group by name", async () => {
|
||||
const user = await buildUser();
|
||||
const user2 = await buildUser({ name: "Won't find" });
|
||||
|
||||
@@ -206,6 +206,7 @@ export async function buildGroup(overrides: Object = {}) {
|
||||
return Group.create({
|
||||
name: `Test Group ${count}`,
|
||||
createdById: overrides.userId,
|
||||
isPrivate: true,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@
|
||||
"You can edit the name and other details at any time, however doing so often might confuse your team mates.": "You can edit the name and other details at any time, however doing so often might confuse your team mates.",
|
||||
"Name": "Name",
|
||||
"Alphabetical": "Alphabetical",
|
||||
"Sort": "Sort",
|
||||
"Saving": "Saving",
|
||||
"Save": "Save",
|
||||
"Export started, you will receive an email when it’s complete.": "Export started, you will receive an email when it’s complete.",
|
||||
@@ -297,15 +298,17 @@
|
||||
"Creating": "Creating",
|
||||
"Create": "Create",
|
||||
"{{ groupName }} was added to the collection": "{{ groupName }} was added to the collection",
|
||||
"Could not add user": "Could not add user",
|
||||
"Could not add group": "Could not add group",
|
||||
"Can’t find the group you’re looking for?": "Can’t find the group you’re looking for?",
|
||||
"Create a group": "Create a group",
|
||||
"Search by group name": "Search by group name",
|
||||
"Search groups": "Search groups",
|
||||
"No groups matching your search": "No groups matching your search",
|
||||
"No groups left to add": "No groups left to add",
|
||||
"No groups found to add": "No groups found to add",
|
||||
"Add": "Add",
|
||||
"{{ userName }} was added to the collection": "{{ userName }} was added to the collection",
|
||||
"Could not add user": "Could not add user",
|
||||
"Need to add someone who’s not yet on the team yet?": "Need to add someone who’s not yet on the team yet?",
|
||||
"Invite people to {{ teamName }}": "Invite people to {{ teamName }}",
|
||||
"Search by name": "Search by name",
|
||||
@@ -400,6 +403,9 @@
|
||||
"A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.": "A team admin (<em>{{ suspendedContactEmail }}</em>) has suspended your account. To re-activate your account, please reach out to them directly.",
|
||||
"Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.": "Are you sure about that? Deleting the <em>{{groupName}}</em> group will cause its members to lose access to collections and documents that it is associated with.",
|
||||
"You can edit the name of this group at any time, however doing so too often might confuse your team mates.": "You can edit the name of this group at any time, however doing so too often might confuse your team mates.",
|
||||
"Access to group": "Access to group",
|
||||
"Only members present in the group know about the group": "Only members present in the group know about the group",
|
||||
"Everyone in the team can view the group": "Everyone in the team can view the group",
|
||||
"{{userName}} was added to the group": "{{userName}} was added to the group",
|
||||
"Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?": "Add team members below to give them access to the group. Need to add someone who’s not yet on the team yet?",
|
||||
"Invite them to {{teamName}}": "Invite them to {{teamName}}",
|
||||
|
||||
Reference in New Issue
Block a user