From 142303b3dec781398d076cc48445ea1c97d38353 Mon Sep 17 00:00:00 2001 From: Nan Yu Date: Sat, 14 Mar 2020 20:48:32 -0700 Subject: [PATCH] feat: Add groups and group permissions (#1204) * WIP - got one API test to pass yay * adds group update endpoint * added group policies * adds groups.list API * adds groups.info * remove comment * WIP * tests for delete * adds group membership list * adds tests for groups list * add and remove user endpoints for group * ask some questions * fix up some issues around primary keys * remove export from group permissions Co-Authored-By: Tom Moor * remove random file * only create events on actual updates, add tests to ensure * adds uniqueness validation to group name * throw validation errors on model and let it pass through the controller * fix linting * WIP * WIP * WIP * WIP * WIP basic edit and delete * basic CRUD for groups and memberships in place * got member counts working * add member count and limit the number of users sent over teh wire to 6 * factor avatar with AvatarWithPresence into its own class * wip * WIP avatars in group lists * WIP collection groups * add and remove group endpoints * wip add collection groups * wip get group adding to collections to work * wip get updating collection group memberships to work * wip get new group modal working * add tests for collection index * include collection groups in the withmemberships scope * tie permissions to group memberships * remove unused import * Update app/components/GroupListItem.js update title copy Co-Authored-By: Tom Moor * Update server/migrations/20191211044318-create-groups.js Co-Authored-By: Tom Moor * Update server/api/groups.js Co-Authored-By: Tom Moor * Update server/api/groups.js Co-Authored-By: Tom Moor * Update app/menus/CollectionMenu.js Co-Authored-By: Tom Moor * Update server/models/Group.js Co-Authored-By: Tom Moor * minor fixes * Update app/scenes/CollectionMembers/AddGroupsToCollection.js Co-Authored-By: Tom Moor * Update app/menus/GroupMenu.js Co-Authored-By: Tom Moor * Update app/menus/GroupMenu.js Co-Authored-By: Tom Moor * Update app/menus/GroupMenu.js Co-Authored-By: Tom Moor * Update app/scenes/Collection.js Co-Authored-By: Tom Moor * Update app/scenes/CollectionMembers/CollectionMembers.js Co-Authored-By: Tom Moor * Update app/scenes/GroupNew.js Co-Authored-By: Tom Moor * Update app/scenes/GroupNew.js Co-Authored-By: Tom Moor * Update app/scenes/Settings/Groups.js Co-Authored-By: Tom Moor * Update server/api/documents.js Co-Authored-By: Tom Moor * Update app/scenes/CollectionMembers/components/CollectionGroupMemberListItem.js Co-Authored-By: Tom Moor * address comments * WIP - getting websocket stuff up and running * socket event for group deletion * wrapped up cascading deletes * lint * flow * fix: UI feedback * fix: Facepile size * fix: Lots of missing await's * Allow clicking facepile on group list item to open members * remove unused route push, grammar * fix: Remove bad analytics events feat: Add group events to audit log * collection. -> collections. * Add groups to entity websocket events (sync create/update/delete) between clients * fix: Users should not be able to see groups they are not a member of * fix: Not caching errors in UI when changing group memberships * fix: Hide unusable UI * test * fix: Tweak language * feat: Automatically open 'add member' modal after creating group Co-authored-by: Tom Moor --- app/components/Avatar/Avatar.js | 5 +- app/components/Avatar/AvatarWithPresence.js | 84 ++++ app/components/Avatar/index.js | 3 + app/components/Collaborators.js | 141 +----- app/components/Facepile.js | 76 +++ app/components/GroupListItem.js | 94 ++++ app/components/List/Item.js | 1 + app/components/Modal.js | 6 + app/components/PaginatedList.js | 6 +- app/components/Sidebar/Settings.js | 7 + app/components/SocketProvider.js | 26 + app/menus/CollectionMenu.js | 4 +- app/menus/GroupMenu.js | 102 ++++ app/models/CollectionGroupMembership.js | 22 + app/models/Group.js | 17 + app/models/GroupMembership.js | 10 + app/routes.js | 2 + app/scenes/Collection.js | 2 +- app/scenes/CollectionEdit.js | 2 +- .../AddGroupsToCollection.js | 135 +++++ .../CollectionMembers/CollectionMembers.js | 145 +++++- .../CollectionGroupMemberListItem.js | 69 +++ .../components/MemberListItem.js | 2 +- .../components/UserListItem.js | 2 +- app/scenes/GroupDelete.js | 59 +++ app/scenes/GroupEdit.js | 71 +++ app/scenes/GroupMembers/AddPeopleToGroup.js | 123 +++++ app/scenes/GroupMembers/GroupMembers.js | 124 +++++ .../components/GroupMemberListItem.js | 61 +++ .../GroupMembers/components/UserListItem.js | 48 ++ app/scenes/GroupMembers/index.js | 3 + app/scenes/GroupNew.js | 91 ++++ app/scenes/Settings/Groups.js | 114 +++++ app/scenes/Settings/Shares.js | 19 +- .../Settings/components/EventListItem.js | 26 +- app/stores/CollectionGroupMembershipsStore.js | 83 ++++ app/stores/GroupMembershipsStore.js | 79 +++ app/stores/GroupsStore.js | 77 +++ app/stores/RootStore.js | 14 + app/stores/UsersStore.js | 37 +- package.json | 2 +- .../__snapshots__/collections.test.js.snap | 25 + server/api/__snapshots__/groups.test.js.snap | 88 ++++ server/api/collections.js | 164 ++++++- server/api/collections.test.js | 407 +++++++++++++++- server/api/documents.js | 1 + server/api/documents.test.js | 18 +- server/api/groups.js | 291 +++++++++++ server/api/groups.test.js | 460 ++++++++++++++++++ server/api/index.js | 3 + server/api/users.js | 20 +- server/events.js | 30 +- .../20191211044318-create-groups.js | 50 ++ .../20191211044319-create-group-users.js | 49 ++ ...20200122083721-create-collection-groups.js | 53 ++ server/models/ApiKey.js | 1 + server/models/Collection.js | 90 +++- server/models/Collection.test.js | 47 ++ server/models/CollectionGroup.js | 38 ++ server/models/Document.js | 18 +- server/models/Event.js | 5 + server/models/Group.js | 83 ++++ server/models/Group.test.js | 48 ++ server/models/GroupUser.js | 33 ++ server/models/Team.js | 5 +- server/models/User.js | 25 +- server/models/index.js | 9 + server/policies/collection.js | 45 +- server/policies/group.js | 26 + server/policies/index.js | 7 +- server/policies/team.js | 6 + .../presenters/collectionGroupMembership.js | 18 + server/presenters/group.js | 11 + server/presenters/groupMembership.js | 18 + server/presenters/index.js | 6 + server/sequelize.js | 1 + server/services/websockets.js | 268 +++++++++- server/test/factories.js | 45 +- shared/constants.js | 1 + shared/utils/routeHelpers.js | 4 + yarn.lock | 5 + 81 files changed, 4259 insertions(+), 257 deletions(-) create mode 100644 app/components/Avatar/AvatarWithPresence.js create mode 100644 app/components/Facepile.js create mode 100644 app/components/GroupListItem.js create mode 100644 app/menus/GroupMenu.js create mode 100644 app/models/CollectionGroupMembership.js create mode 100644 app/models/Group.js create mode 100644 app/models/GroupMembership.js create mode 100644 app/scenes/CollectionMembers/AddGroupsToCollection.js create mode 100644 app/scenes/CollectionMembers/components/CollectionGroupMemberListItem.js create mode 100644 app/scenes/GroupDelete.js create mode 100644 app/scenes/GroupEdit.js create mode 100644 app/scenes/GroupMembers/AddPeopleToGroup.js create mode 100644 app/scenes/GroupMembers/GroupMembers.js create mode 100644 app/scenes/GroupMembers/components/GroupMemberListItem.js create mode 100644 app/scenes/GroupMembers/components/UserListItem.js create mode 100644 app/scenes/GroupMembers/index.js create mode 100644 app/scenes/GroupNew.js create mode 100644 app/scenes/Settings/Groups.js create mode 100644 app/stores/CollectionGroupMembershipsStore.js create mode 100644 app/stores/GroupMembershipsStore.js create mode 100644 app/stores/GroupsStore.js create mode 100644 server/api/__snapshots__/groups.test.js.snap create mode 100644 server/api/groups.js create mode 100644 server/api/groups.test.js create mode 100644 server/migrations/20191211044318-create-groups.js create mode 100644 server/migrations/20191211044319-create-group-users.js create mode 100644 server/migrations/20200122083721-create-collection-groups.js create mode 100644 server/models/CollectionGroup.js create mode 100644 server/models/Group.js create mode 100644 server/models/Group.test.js create mode 100644 server/models/GroupUser.js create mode 100644 server/policies/group.js create mode 100644 server/presenters/collectionGroupMembership.js create mode 100644 server/presenters/group.js create mode 100644 server/presenters/groupMembership.js diff --git a/app/components/Avatar/Avatar.js b/app/components/Avatar/Avatar.js index a0c1f324..e5970971 100644 --- a/app/components/Avatar/Avatar.js +++ b/app/components/Avatar/Avatar.js @@ -39,11 +39,11 @@ class Avatar extends React.Component { } } -const AvatarWrapper = styled.span` +const AvatarWrapper = styled.div` position: relative; `; -const IconWrapper = styled.span` +const IconWrapper = styled.div` display: flex; position: absolute; bottom: -2px; @@ -56,6 +56,7 @@ const IconWrapper = styled.span` `; const CircleImg = styled.img` + display: block; width: ${props => props.size}px; height: ${props => props.size}px; border-radius: 50%; diff --git a/app/components/Avatar/AvatarWithPresence.js b/app/components/Avatar/AvatarWithPresence.js new file mode 100644 index 00000000..3518fbcf --- /dev/null +++ b/app/components/Avatar/AvatarWithPresence.js @@ -0,0 +1,84 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import styled from 'styled-components'; +import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +import Avatar from 'components/Avatar'; +import Tooltip from 'components/Tooltip'; +import User from 'models/User'; +import UserProfile from 'scenes/UserProfile'; +import { EditIcon } from 'outline-icons'; + +type Props = { + user: User, + isPresent: boolean, + isEditing: boolean, + isCurrentUser: boolean, + lastViewedAt: string, +}; + +@observer +class AvatarWithPresence extends React.Component { + @observable isOpen: boolean = false; + + handleOpenProfile = () => { + this.isOpen = true; + }; + + handleCloseProfile = () => { + this.isOpen = false; + }; + + render() { + const { + user, + lastViewedAt, + isPresent, + isEditing, + isCurrentUser, + } = this.props; + + return ( + + + {user.name} {isCurrentUser && '(You)'} +
+ {isPresent + ? isEditing ? 'currently editing' : 'currently viewing' + : `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`} + + } + placement="bottom" + > + + : undefined} + /> + +
+ +
+ ); + } +} + +const Centered = styled.div` + text-align: center; +`; + +const AvatarWrapper = styled.div` + opacity: ${props => (props.isPresent ? 1 : 0.5)}; + transition: opacity 250ms ease-in-out; +`; + +export default AvatarWithPresence; diff --git a/app/components/Avatar/index.js b/app/components/Avatar/index.js index 710c5ee8..cd77b1bb 100644 --- a/app/components/Avatar/index.js +++ b/app/components/Avatar/index.js @@ -1,3 +1,6 @@ // @flow import Avatar from './Avatar'; +import AvatarWithPresence from './AvatarWithPresence'; + +export { AvatarWithPresence }; export default Avatar; diff --git a/app/components/Collaborators.js b/app/components/Collaborators.js index 25eaf0c4..ce1b6e64 100644 --- a/app/components/Collaborators.js +++ b/app/components/Collaborators.js @@ -1,22 +1,14 @@ // @flow import * as React from 'react'; -import { observable } from 'mobx'; import { observer, inject } from 'mobx-react'; -import { sortBy } from 'lodash'; -import styled, { withTheme } from 'styled-components'; -import distanceInWordsToNow from 'date-fns/distance_in_words_to_now'; +import { sortBy, keyBy } from 'lodash'; +import { MAX_AVATAR_DISPLAY } from 'shared/constants'; -import Flex from 'shared/components/Flex'; -import Avatar from 'components/Avatar'; -import Tooltip from 'components/Tooltip'; +import { AvatarWithPresence } from 'components/Avatar'; +import Facepile from 'components/Facepile'; import Document from 'models/Document'; -import User from 'models/User'; -import UserProfile from 'scenes/UserProfile'; import ViewsStore from 'stores/ViewsStore'; import DocumentPresenceStore from 'stores/DocumentPresenceStore'; -import { EditIcon } from 'outline-icons'; - -const MAX_DISPLAY = 6; type Props = { views: ViewsStore, @@ -25,66 +17,6 @@ type Props = { currentUserId: string, }; -@observer -class AvatarWithPresence extends React.Component<{ - user: User, - isPresent: boolean, - isEditing: boolean, - isCurrentUser: boolean, - lastViewedAt: string, -}> { - @observable isOpen: boolean = false; - - handleOpenProfile = () => { - this.isOpen = true; - }; - - handleCloseProfile = () => { - this.isOpen = false; - }; - - render() { - const { - user, - lastViewedAt, - isPresent, - isEditing, - isCurrentUser, - } = this.props; - - return ( - - - {user.name} {isCurrentUser && '(You)'} -
- {isPresent - ? isEditing ? 'currently editing' : 'currently viewing' - : `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`} - - } - placement="bottom" - > - - : undefined} - /> - -
- -
- ); - } -} - @observer class Collaborators extends React.Component { componentDidMount() { @@ -93,33 +25,37 @@ class Collaborators extends React.Component { render() { const { document, presence, views, currentUserId } = this.props; - const documentViews = views.inDocument(document.id); let documentPresence = presence.get(document.id); documentPresence = documentPresence ? Array.from(documentPresence.values()) : []; + + const documentViews = views.inDocument(document.id); + const presentIds = documentPresence.map(p => p.userId); const editingIds = documentPresence .filter(p => p.isEditing) .map(p => p.userId); - // only show the most recent viewers, the rest can overflow - let mostRecentViewers = documentViews.slice(0, MAX_DISPLAY); - // ensure currently present via websocket are always ordered first - mostRecentViewers = sortBy(mostRecentViewers, view => - presentIds.includes(view.user.id) + const mostRecentViewers = sortBy( + documentViews.slice(0, MAX_AVATAR_DISPLAY), + view => { + return presentIds.includes(view.user.id); + } ); - // if there are too many to display then add a (+X) to the UI + const viewersKeyedByUserId = keyBy(mostRecentViewers, v => v.user.id); const overflow = documentViews.length - mostRecentViewers.length; return ( - - {overflow > 0 && +{overflow}} - {mostRecentViewers.map(({ lastViewedAt, user }) => { + v.user)} + overflow={overflow} + renderAvatar={user => { const isPresent = presentIds.includes(user.id); const isEditing = editingIds.includes(user.id); + const { lastViewedAt } = viewersKeyedByUserId[user.id]; return ( { isCurrentUser={currentUserId === user.id} /> ); - })} - + }} + /> ); } } -const Centered = styled.div` - text-align: center; -`; - -const AvatarWrapper = styled.div` - width: 32px; - height: 32px; - margin-right: -8px; - opacity: ${props => (props.isPresent ? 1 : 0.5)}; - transition: opacity 250ms ease-in-out; - - &:first-child { - margin-right: 0; - } -`; - -const More = styled.div` - min-width: 30px; - height: 24px; - border-radius: 12px; - background: ${props => props.theme.slate}; - color: ${props => props.theme.text}; - border: 2px solid ${props => props.theme.background}; - text-align: center; - line-height: 20px; - font-size: 11px; - font-weight: 600; -`; - -const Avatars = styled(Flex)` - align-items: center; - flex-direction: row-reverse; - cursor: pointer; -`; - -export default inject('views', 'presence')(withTheme(Collaborators)); +export default inject('views', 'presence')(Collaborators); diff --git a/app/components/Facepile.js b/app/components/Facepile.js new file mode 100644 index 00000000..3511bd2d --- /dev/null +++ b/app/components/Facepile.js @@ -0,0 +1,76 @@ +// @flow +import * as React from 'react'; +import { observer, inject } from 'mobx-react'; +import styled, { withTheme } from 'styled-components'; +import Flex from 'shared/components/Flex'; +import Avatar from 'components/Avatar'; +import User from 'models/User'; + +type Props = { + users: User[], + size?: number, + overflow: number, + renderAvatar: (user: User) => React.Node, +}; + +@observer +class Facepile extends React.Component { + render() { + const { + users, + overflow, + size = 32, + renderAvatar = renderDefaultAvatar, + ...rest + } = this.props; + + return ( + + {overflow > 0 && ( + + +{overflow} + + )} + {users.map(user => ( + {renderAvatar(user)} + ))} + + ); + } +} + +function renderDefaultAvatar(user: User) { + return ; +} + +const AvatarWrapper = styled.div` + margin-right: -8px; + + &:first-child { + margin-right: 0; + } +`; + +const More = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: ${props => props.size}px; + height: ${props => props.size}px; + border-radius: 100%; + background: ${props => props.theme.slate}; + color: ${props => props.theme.text}; + border: 2px solid ${props => props.theme.background}; + text-align: center; + font-size: 11px; + font-weight: 600; +`; + +const Avatars = styled(Flex)` + align-items: center; + flex-direction: row-reverse; + cursor: pointer; +`; + +export default inject('views', 'presence')(withTheme(Facepile)); diff --git a/app/components/GroupListItem.js b/app/components/GroupListItem.js new file mode 100644 index 00000000..3d35c6ca --- /dev/null +++ b/app/components/GroupListItem.js @@ -0,0 +1,94 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { observable } from 'mobx'; +import { observer, inject } from 'mobx-react'; +import { MAX_AVATAR_DISPLAY } from 'shared/constants'; +import Modal from 'components/Modal'; +import Flex from 'shared/components/Flex'; +import Facepile from 'components/Facepile'; +import GroupMembers from 'scenes/GroupMembers'; +import ListItem from 'components/List/Item'; +import Group from 'models/Group'; +import CollectionGroupMembership from 'models/CollectionGroupMembership'; +import GroupMembershipsStore from 'stores/GroupMembershipsStore'; + +type Props = { + group: Group, + groupMemberships: GroupMembershipsStore, + membership?: CollectionGroupMembership, + showFacepile: boolean, + renderActions: ({ openMembersModal: () => void }) => React.Node, +}; + +@observer +class GroupListItem extends React.Component { + @observable membersModalOpen: boolean = false; + + handleMembersModalOpen = () => { + this.membersModalOpen = true; + }; + + handleMembersModalClose = () => { + this.membersModalOpen = false; + }; + + render() { + const { group, groupMemberships, showFacepile, renderActions } = this.props; + + const memberCount = group.memberCount; + + const membershipsInGroup = groupMemberships.inGroup(group.id); + const users = membershipsInGroup + .slice(0, MAX_AVATAR_DISPLAY) + .map(gm => gm.user); + + const overflow = memberCount - users.length; + + return ( + + {group.name} + } + subtitle={ + + {memberCount} member{memberCount === 1 ? '' : 's'} + + } + actions={ + + {showFacepile && ( + + )} +   + {renderActions({ + openMembersModal: this.handleMembersModalOpen, + })} + + } + /> + + + + + ); + } +} + +const Title = styled.span` + &:hover { + text-decoration: underline; + cursor: pointer; + } +`; + +export default inject('groupMemberships')(GroupListItem); diff --git a/app/components/List/Item.js b/app/components/List/Item.js index 165967fb..1394ff00 100644 --- a/app/components/List/Item.js +++ b/app/components/List/Item.js @@ -42,6 +42,7 @@ const Image = styled(Flex)` align-items: center; user-select: none; flex-shrink: 0; + align-self: flex-start; `; const Heading = styled.p` diff --git a/app/components/Modal.js b/app/components/Modal.js index 5b6d15b8..327a84cc 100644 --- a/app/components/Modal.js +++ b/app/components/Modal.js @@ -41,6 +41,12 @@ const GlobalStyles = createGlobalStyle` margin-left: 24px; } } + + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal { + .ReactModal__Overlay { + margin-left: 36px; + } + } `}; .ReactModal__Body--open { diff --git a/app/components/PaginatedList.js b/app/components/PaginatedList.js index 2f4fe8cb..6611c926 100644 --- a/app/components/PaginatedList.js +++ b/app/components/PaginatedList.js @@ -27,8 +27,12 @@ class PaginatedList extends React.Component { @observable offset: number = 0; @observable allowLoadMore: boolean = true; + constructor(props: Props) { + super(props); + this.isInitiallyLoaded = this.props.items.length > 0; + } + componentDidMount() { - this.isInitiallyLoaded = !!this.props.items.length; this.fetchResults(); } diff --git a/app/components/Sidebar/Settings.js b/app/components/Sidebar/Settings.js index 81f6661d..85b46712 100644 --- a/app/components/Sidebar/Settings.js +++ b/app/components/Sidebar/Settings.js @@ -9,6 +9,7 @@ import { PadlockIcon, CodeIcon, UserIcon, + GroupIcon, LinkIcon, TeamIcon, BulletedListIcon, @@ -96,6 +97,12 @@ class SettingsSidebar extends React.Component { exact={false} label="People" /> + } + exact={false} + label="Groups" + /> } diff --git a/app/components/SocketProvider.js b/app/components/SocketProvider.js index 135d0404..43d179fd 100644 --- a/app/components/SocketProvider.js +++ b/app/components/SocketProvider.js @@ -6,6 +6,7 @@ import { find } from 'lodash'; import io from 'socket.io-client'; import DocumentsStore from 'stores/DocumentsStore'; import CollectionsStore from 'stores/CollectionsStore'; +import GroupsStore from 'stores/GroupsStore'; import MembershipsStore from 'stores/MembershipsStore'; import DocumentPresenceStore from 'stores/DocumentPresenceStore'; import PoliciesStore from 'stores/PoliciesStore'; @@ -19,6 +20,7 @@ type Props = { children: React.Node, documents: DocumentsStore, collections: CollectionsStore, + groups: GroupsStore, memberships: MembershipsStore, presence: DocumentPresenceStore, policies: PoliciesStore, @@ -44,6 +46,7 @@ class SocketProvider extends React.Component { ui, documents, collections, + groups, memberships, policies, presence, @@ -173,6 +176,28 @@ class SocketProvider extends React.Component { } } } + + if (event.groupIds) { + for (const groupDescriptor of event.groupIds) { + const groupId = groupDescriptor.id; + const group = groups.get(groupId) || {}; + + // if we already have the latest version (it was us that performed + // the change) then we don't need to update anything either. + const { updatedAt } = group; + if (updatedAt === groupDescriptor.updatedAt) { + continue; + } + + try { + await groups.fetch(groupId, { force: true }); + } catch (err) { + if (err.statusCode === 404 || err.statusCode === 403) { + groups.remove(groupId); + } + } + } + } }); this.socket.on('documents.star', event => { @@ -270,6 +295,7 @@ export default inject( 'ui', 'documents', 'collections', + 'groups', 'memberships', 'presence', 'policies', diff --git a/app/menus/CollectionMenu.js b/app/menus/CollectionMenu.js index 13081f04..5610d359 100644 --- a/app/menus/CollectionMenu.js +++ b/app/menus/CollectionMenu.js @@ -105,7 +105,7 @@ class CollectionMenu extends React.Component { @@ -134,7 +134,7 @@ class CollectionMenu extends React.Component { )} {can.update && ( - Members… + Permissions… )} {can.export && ( diff --git a/app/menus/GroupMenu.js b/app/menus/GroupMenu.js new file mode 100644 index 00000000..bab5b2a9 --- /dev/null +++ b/app/menus/GroupMenu.js @@ -0,0 +1,102 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { inject, observer } from 'mobx-react'; +import { withRouter, type RouterHistory } from 'react-router-dom'; +import Modal from 'components/Modal'; +import GroupEdit from 'scenes/GroupEdit'; +import GroupDelete from 'scenes/GroupDelete'; + +import Group from 'models/Group'; +import UiStore from 'stores/UiStore'; +import PoliciesStore from 'stores/PoliciesStore'; +import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; + +type Props = { + ui: UiStore, + policies: PoliciesStore, + group: Group, + history: RouterHistory, + onMembers: () => void, + onOpen?: () => void, + onClose?: () => void, +}; + +@observer +class GroupMenu extends React.Component { + @observable editModalOpen: boolean = false; + @observable deleteModalOpen: boolean = false; + + onEdit = (ev: SyntheticEvent<>) => { + ev.preventDefault(); + this.editModalOpen = true; + }; + + onDelete = (ev: SyntheticEvent<>) => { + ev.preventDefault(); + this.deleteModalOpen = true; + }; + + handleEditModalClose = () => { + this.editModalOpen = false; + }; + + handleDeleteModalClose = () => { + this.deleteModalOpen = false; + }; + + render() { + const { policies, group, onOpen, onClose } = this.props; + const can = policies.abilities(group.id); + + return ( + + + + + + + + + + + {group && ( + + + Members… + + + {(can.update || can.delete) &&
} + + {can.update && ( + Edit… + )} + + {can.delete && ( + + Delete… + + )} +
+ )} +
+
+ ); + } +} + +export default inject('policies')(withRouter(GroupMenu)); diff --git a/app/models/CollectionGroupMembership.js b/app/models/CollectionGroupMembership.js new file mode 100644 index 00000000..ef529f93 --- /dev/null +++ b/app/models/CollectionGroupMembership.js @@ -0,0 +1,22 @@ +// @flow +import { computed } from 'mobx'; +import BaseModel from './BaseModel'; + +class CollectionGroupMembership extends BaseModel { + id: string; + groupId: string; + collectionId: string; + permission: string; + + @computed + get isEditor(): boolean { + return this.permission === 'read_write'; + } + + @computed + get isMaintainer(): boolean { + return this.permission === 'maintainer'; + } +} + +export default CollectionGroupMembership; diff --git a/app/models/Group.js b/app/models/Group.js new file mode 100644 index 00000000..56f16a3e --- /dev/null +++ b/app/models/Group.js @@ -0,0 +1,17 @@ +// @flow +import BaseModel from './BaseModel'; + +class Group extends BaseModel { + id: string; + name: string; + memberCount: number; + updatedAt: string; + + toJS = () => { + return { + name: this.name, + }; + }; +} + +export default Group; diff --git a/app/models/GroupMembership.js b/app/models/GroupMembership.js new file mode 100644 index 00000000..cd769862 --- /dev/null +++ b/app/models/GroupMembership.js @@ -0,0 +1,10 @@ +// @flow +import BaseModel from './BaseModel'; + +class GroupMembership extends BaseModel { + id: string; + userId: string; + groupId: string; +} + +export default GroupMembership; diff --git a/app/routes.js b/app/routes.js index 4049d1c0..da15ad12 100644 --- a/app/routes.js +++ b/app/routes.js @@ -16,6 +16,7 @@ import Details from 'scenes/Settings/Details'; import Notifications from 'scenes/Settings/Notifications'; import Security from 'scenes/Settings/Security'; import People from 'scenes/Settings/People'; +import Groups from 'scenes/Settings/Groups'; import Slack from 'scenes/Settings/Slack'; import Zapier from 'scenes/Settings/Zapier'; import Shares from 'scenes/Settings/Shares'; @@ -56,6 +57,7 @@ export default function Routes() { + diff --git a/app/scenes/Collection.js b/app/scenes/Collection.js index 60de729f..d8592a91 100644 --- a/app/scenes/Collection.js +++ b/app/scenes/Collection.js @@ -186,7 +186,7 @@ class CollectionScene extends React.Component { )} diff --git a/app/scenes/CollectionEdit.js b/app/scenes/CollectionEdit.js index a0df8037..71eecca5 100644 --- a/app/scenes/CollectionEdit.js +++ b/app/scenes/CollectionEdit.js @@ -26,7 +26,7 @@ class CollectionEdit extends React.Component { @observable isSaving: boolean; @observable private: boolean = false; - componentWillMount() { + componentDidMount() { this.name = this.props.collection.name; this.description = this.props.collection.description; this.color = this.props.collection.color; diff --git a/app/scenes/CollectionMembers/AddGroupsToCollection.js b/app/scenes/CollectionMembers/AddGroupsToCollection.js new file mode 100644 index 00000000..b805bad2 --- /dev/null +++ b/app/scenes/CollectionMembers/AddGroupsToCollection.js @@ -0,0 +1,135 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import { inject, observer } from 'mobx-react'; +import { observable } from 'mobx'; +import { debounce } from 'lodash'; +import Button from 'components/Button'; +import Flex from 'shared/components/Flex'; +import HelpText from 'components/HelpText'; +import Input from 'components/Input'; +import Modal from 'components/Modal'; +import Empty from 'components/Empty'; +import PaginatedList from 'components/PaginatedList'; +import GroupNew from 'scenes/GroupNew'; +import Collection from 'models/Collection'; +import UiStore from 'stores/UiStore'; +import AuthStore from 'stores/AuthStore'; +import GroupsStore from 'stores/GroupsStore'; +import CollectionGroupMembershipsStore from 'stores/CollectionGroupMembershipsStore'; +import GroupListItem from 'components/GroupListItem'; + +type Props = { + ui: UiStore, + auth: AuthStore, + collection: Collection, + collectionGroupMemberships: CollectionGroupMembershipsStore, + groups: GroupsStore, + onSubmit: () => void, +}; + +@observer +class AddGroupsToCollection extends React.Component { + @observable newGroupModalOpen: boolean = false; + @observable query: string = ''; + + handleNewGroupModalOpen = () => { + this.newGroupModalOpen = true; + }; + + handleNewGroupModalClose = () => { + this.newGroupModalOpen = false; + }; + + handleFilter = (ev: SyntheticInputEvent) => { + this.query = ev.target.value; + this.debouncedFetch(); + }; + + debouncedFetch = debounce(() => { + this.props.groups.fetchPage({ + query: this.query, + }); + }, 250); + + handleAddGroup = group => { + try { + this.props.collectionGroupMemberships.create({ + collectionId: this.props.collection.id, + groupId: group.id, + permission: 'read_write', + }); + this.props.ui.showToast(`${group.name} was added to the collection`); + } catch (err) { + this.props.ui.showToast('Could not add user'); + console.error(err); + } + }; + + render() { + const { groups, collection, auth } = this.props; + const { user, team } = auth; + if (!user || !team) return null; + + return ( + + + Can’t find the group you’re looking for?{' '} + + Create a group + . + + + + No groups matching your search + ) : ( + No groups left to add + ) + } + items={groups.notInCollection(collection.id, this.query)} + fetch={this.query ? undefined : groups.fetchPage} + renderItem={item => ( + ( + + + + )} + /> + )} + /> + + + + + ); + } +} + +const ButtonWrap = styled.div` + margin-left: 6px; +`; + +export default inject('auth', 'groups', 'collectionGroupMemberships', 'ui')( + AddGroupsToCollection +); diff --git a/app/scenes/CollectionMembers/CollectionMembers.js b/app/scenes/CollectionMembers/CollectionMembers.js index b6cb54e0..2e56703f 100644 --- a/app/scenes/CollectionMembers/CollectionMembers.js +++ b/app/scenes/CollectionMembers/CollectionMembers.js @@ -1,21 +1,27 @@ // @flow import * as React from 'react'; import { observable } from 'mobx'; +import styled from 'styled-components'; import { inject, observer } from 'mobx-react'; import { PlusIcon } from 'outline-icons'; import Flex from 'shared/components/Flex'; import HelpText from 'components/HelpText'; import Subheading from 'components/Subheading'; import Button from 'components/Button'; +import Empty from 'components/Empty'; import PaginatedList from 'components/PaginatedList'; import Modal from 'components/Modal'; +import CollectionGroupMemberListItem from './components/CollectionGroupMemberListItem'; import Collection from 'models/Collection'; import UiStore from 'stores/UiStore'; import AuthStore from 'stores/AuthStore'; import MembershipsStore from 'stores/MembershipsStore'; +import CollectionGroupMembershipsStore from 'stores/CollectionGroupMembershipsStore'; import UsersStore from 'stores/UsersStore'; import MemberListItem from './components/MemberListItem'; import AddPeopleToCollection from './AddPeopleToCollection'; +import AddGroupsToCollection from './AddGroupsToCollection'; +import GroupsStore from 'stores/GroupsStore'; type Props = { ui: UiStore, @@ -23,19 +29,30 @@ type Props = { collection: Collection, users: UsersStore, memberships: MembershipsStore, + collectionGroupMemberships: CollectionGroupMembershipsStore, + groups: GroupsStore, onEdit: () => void, }; @observer class CollectionMembers extends React.Component { - @observable addModalOpen: boolean = false; + @observable addGroupModalOpen: boolean = false; + @observable addMemberModalOpen: boolean = false; - handleAddModalOpen = () => { - this.addModalOpen = true; + handleAddGroupModalOpen = () => { + this.addGroupModalOpen = true; }; - handleAddModalClose = () => { - this.addModalOpen = false; + handleAddGroupModalClose = () => { + this.addGroupModalOpen = false; + }; + + handleAddMemberModalOpen = () => { + this.addMemberModalOpen = true; + }; + + handleAddMemberModalClose = () => { + this.addMemberModalOpen = false; }; handleRemoveUser = user => { @@ -63,8 +80,40 @@ class CollectionMembers extends React.Component { } }; + handleRemoveGroup = group => { + try { + this.props.collectionGroupMemberships.delete({ + collectionId: this.props.collection.id, + groupId: group.id, + }); + this.props.ui.showToast(`${group.name} was removed from the collection`); + } catch (err) { + this.props.ui.showToast('Could not remove group'); + } + }; + + handleUpdateGroup = (group, permission) => { + try { + this.props.collectionGroupMemberships.create({ + collectionId: this.props.collection.id, + groupId: group.id, + permission, + }); + this.props.ui.showToast(`${group.name} permissions were updated`); + } catch (err) { + this.props.ui.showToast('Could not update user'); + } + }; + render() { - const { collection, users, memberships, auth } = this.props; + const { + collection, + users, + groups, + memberships, + collectionGroupMemberships, + auth, + } = this.props; const { user } = auth; if (!user) return null; @@ -78,9 +127,10 @@ class CollectionMembers extends React.Component { {collection.private ? ( - Choose which team members have access to view and edit documents - in the private {collection.name} collection. You - can make this collection visible to the entire team by{' '} + Choose which groups and team members have access to view and edit + documents in the private {collection.name}{' '} + collection. You can make this collection visible to the entire + team by{' '} changing its visibility . @@ -88,11 +138,11 @@ class CollectionMembers extends React.Component { @@ -107,7 +157,59 @@ class CollectionMembers extends React.Component { )} - Members + {collection.private && ( + + Groups + This collection has no groups.} + renderItem={group => ( + this.handleRemoveGroup(group)} + onUpdate={permission => + this.handleUpdateGroup(group, permission) + } + /> + )} + /> + + + + + )} + {collection.private ? ( + + + + + + Individual Members + + ) : ( + Members + )} { /> @@ -143,4 +245,15 @@ class CollectionMembers extends React.Component { } } -export default inject('auth', 'users', 'memberships', 'ui')(CollectionMembers); +const GroupsWrap = styled.div` + margin-bottom: 50px; +`; + +export default inject( + 'auth', + 'users', + 'memberships', + 'collectionGroupMemberships', + 'groups', + 'ui' +)(CollectionMembers); diff --git a/app/scenes/CollectionMembers/components/CollectionGroupMemberListItem.js b/app/scenes/CollectionMembers/components/CollectionGroupMemberListItem.js new file mode 100644 index 00000000..d57e5d0d --- /dev/null +++ b/app/scenes/CollectionMembers/components/CollectionGroupMemberListItem.js @@ -0,0 +1,69 @@ +// @flow +import * as React from 'react'; +import styled from 'styled-components'; +import InputSelect from 'components/InputSelect'; +import GroupListItem from 'components/GroupListItem'; +import Group from 'models/Group'; +import CollectionGroupMembership from 'models/CollectionGroupMembership'; +import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; + +const PERMISSIONS = [ + { label: 'Read only', value: 'read' }, + { label: 'Read & Edit', value: 'read_write' }, +]; +type Props = { + group: Group, + collectionGroupMembership: ?CollectionGroupMembership, + onUpdate: (permission: string) => void, + onRemove: () => void, +}; + +const MemberListItem = ({ + group, + collectionGroupMembership, + onUpdate, + onRemove, +}: Props) => { + return ( + ( + + + + + + + ); + } +} + +export default inject('ui')(withRouter(GroupEdit)); diff --git a/app/scenes/GroupMembers/AddPeopleToGroup.js b/app/scenes/GroupMembers/AddPeopleToGroup.js new file mode 100644 index 00000000..3b77e46e --- /dev/null +++ b/app/scenes/GroupMembers/AddPeopleToGroup.js @@ -0,0 +1,123 @@ +// @flow +import * as React from 'react'; +import { inject, observer } from 'mobx-react'; +import { observable } from 'mobx'; +import { debounce } from 'lodash'; +import Flex from 'shared/components/Flex'; +import HelpText from 'components/HelpText'; +import Input from 'components/Input'; +import Modal from 'components/Modal'; +import Empty from 'components/Empty'; +import PaginatedList from 'components/PaginatedList'; +import Invite from 'scenes/Invite'; +import Group from 'models/Group'; +import UiStore from 'stores/UiStore'; +import AuthStore from 'stores/AuthStore'; +import UsersStore from 'stores/UsersStore'; +import GroupMembershipsStore from 'stores/GroupMembershipsStore'; +import GroupMemberListItem from './components/GroupMemberListItem'; + +type Props = { + ui: UiStore, + auth: AuthStore, + group: Group, + groupMemberships: GroupMembershipsStore, + users: UsersStore, + onSubmit: () => void, +}; + +@observer +class AddPeopleToGroup extends React.Component { + @observable inviteModalOpen: boolean = false; + @observable query: string = ''; + + handleInviteModalOpen = () => { + this.inviteModalOpen = true; + }; + + handleInviteModalClose = () => { + this.inviteModalOpen = false; + }; + + handleFilter = (ev: SyntheticInputEvent) => { + this.query = ev.target.value; + this.debouncedFetch(); + }; + + debouncedFetch = debounce(() => { + this.props.users.fetchPage({ + query: this.query, + }); + }, 250); + + handleAddUser = async user => { + try { + await this.props.groupMemberships.create({ + groupId: this.props.group.id, + userId: user.id, + }); + this.props.ui.showToast(`${user.name} was added to the group`); + } catch (err) { + this.props.ui.showToast('Could not add user'); + } + }; + + render() { + const { users, group, auth } = this.props; + const { user, team } = auth; + if (!user || !team) return null; + + return ( + + + 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 {team.name} + . + + + + No people matching your search + ) : ( + No people left to add + ) + } + items={users.notInGroup(group.id, this.query)} + fetch={this.query ? undefined : users.fetchPage} + renderItem={item => ( + this.handleAddUser(item)} + canEdit + /> + )} + /> + + + + + ); + } +} + +export default inject('auth', 'users', 'groupMemberships', 'ui')( + AddPeopleToGroup +); diff --git a/app/scenes/GroupMembers/GroupMembers.js b/app/scenes/GroupMembers/GroupMembers.js new file mode 100644 index 00000000..7ecc6aab --- /dev/null +++ b/app/scenes/GroupMembers/GroupMembers.js @@ -0,0 +1,124 @@ +// @flow +import * as React from 'react'; +import { observable } from 'mobx'; +import { inject, observer } from 'mobx-react'; +import { PlusIcon } from 'outline-icons'; +import Flex from 'shared/components/Flex'; +import Empty from 'components/Empty'; +import HelpText from 'components/HelpText'; +import Subheading from 'components/Subheading'; +import Button from 'components/Button'; +import PaginatedList from 'components/PaginatedList'; +import Modal from 'components/Modal'; +import Group from 'models/Group'; +import UiStore from 'stores/UiStore'; +import AuthStore from 'stores/AuthStore'; +import GroupMembershipsStore from 'stores/GroupMembershipsStore'; +import UsersStore from 'stores/UsersStore'; +import PoliciesStore from 'stores/PoliciesStore'; +import GroupMemberListItem from './components/GroupMemberListItem'; +import AddPeopleToGroup from './AddPeopleToGroup'; + +type Props = { + ui: UiStore, + auth: AuthStore, + group: Group, + users: UsersStore, + policies: PoliciesStore, + groupMemberships: GroupMembershipsStore, +}; + +@observer +class GroupMembers extends React.Component { + @observable addModalOpen: boolean = false; + + handleAddModalOpen = () => { + this.addModalOpen = true; + }; + + handleAddModalClose = () => { + this.addModalOpen = false; + }; + + handleRemoveUser = async user => { + try { + await this.props.groupMemberships.delete({ + groupId: this.props.group.id, + userId: user.id, + }); + this.props.ui.showToast(`${user.name} was removed from the group`); + } catch (err) { + this.props.ui.showToast('Could not remove user'); + } + }; + + render() { + const { group, users, groupMemberships, policies, auth } = this.props; + const { user } = auth; + if (!user) return null; + + const can = policies.abilities(group.id); + + return ( + + {can.update ? ( + + + Add and remove team members in the {group.name}{' '} + group. Adding people to the group will give them access to any + collections this group has been given access to. + + + + + + ) : ( + + Listing team members in the {group.name} group. + + )} + + Members + This group has no members.} + renderItem={item => ( + this.handleRemoveUser(item) : undefined + } + /> + )} + /> + {can.update && ( + + + + )} + + ); + } +} + +export default inject('auth', 'users', 'policies', 'groupMemberships', 'ui')( + GroupMembers +); diff --git a/app/scenes/GroupMembers/components/GroupMemberListItem.js b/app/scenes/GroupMembers/components/GroupMemberListItem.js new file mode 100644 index 00000000..7f5d3d04 --- /dev/null +++ b/app/scenes/GroupMembers/components/GroupMemberListItem.js @@ -0,0 +1,61 @@ +// @flow +import * as React from 'react'; +import Avatar from 'components/Avatar'; +import Flex from 'shared/components/Flex'; +import Time from 'shared/components/Time'; +import Badge from 'components/Badge'; +import Button from 'components/Button'; +import ListItem from 'components/List/Item'; +import User from 'models/User'; +import GroupMembership from 'models/GroupMembership'; +import { DropdownMenu, DropdownMenuItem } from 'components/DropdownMenu'; + +type Props = { + user: User, + groupMembership?: ?GroupMembership, + onAdd?: () => Promise, + onRemove?: () => Promise, +}; + +const GroupMemberListItem = ({ + user, + groupMembership, + onRemove, + onAdd, +}: Props) => { + return ( + + {user.lastActiveAt ? ( + + Active + ) : ( + 'Never signed in' + )} + {!user.lastActiveAt && Invited} + {user.isAdmin && Admin} + + } + image={} + actions={ + + {onRemove && ( + + Remove + + )} + {onAdd && ( + + )} + + } + /> + ); +}; + +export default GroupMemberListItem; diff --git a/app/scenes/GroupMembers/components/UserListItem.js b/app/scenes/GroupMembers/components/UserListItem.js new file mode 100644 index 00000000..a12bfe5e --- /dev/null +++ b/app/scenes/GroupMembers/components/UserListItem.js @@ -0,0 +1,48 @@ +// @flow +import * as React from 'react'; +import { PlusIcon } from 'outline-icons'; +import Time from 'shared/components/Time'; +import Avatar from 'components/Avatar'; +import Button from 'components/Button'; +import Badge from 'components/Badge'; +import ListItem from 'components/List/Item'; +import User from 'models/User'; + +type Props = { + user: User, + canEdit: boolean, + onAdd: () => void, +}; + +const UserListItem = ({ user, onAdd, canEdit }: Props) => { + return ( + } + subtitle={ + + {user.lastActiveAt ? ( + + Active + ) : ( + 'Never signed in' + )} + {!user.lastActiveAt && Invited} + {user.isAdmin && Admin} + + } + actions={ + canEdit ? ( + + ) : ( + undefined + ) + } + /> + ); +}; + +export default UserListItem; diff --git a/app/scenes/GroupMembers/index.js b/app/scenes/GroupMembers/index.js new file mode 100644 index 00000000..4f97042c --- /dev/null +++ b/app/scenes/GroupMembers/index.js @@ -0,0 +1,3 @@ +// @flow +import GroupMembers from './GroupMembers'; +export default GroupMembers; diff --git a/app/scenes/GroupNew.js b/app/scenes/GroupNew.js new file mode 100644 index 00000000..91fb3185 --- /dev/null +++ b/app/scenes/GroupNew.js @@ -0,0 +1,91 @@ +// @flow +import * as React from 'react'; +import { withRouter, type RouterHistory } from 'react-router-dom'; +import { observable } from 'mobx'; +import { inject, observer } from 'mobx-react'; +import Button from 'components/Button'; +import Input from 'components/Input'; +import HelpText from 'components/HelpText'; +import Modal from 'components/Modal'; +import GroupMembers from 'scenes/GroupMembers'; +import Flex from 'shared/components/Flex'; + +import Group from 'models/Group'; +import GroupsStore from 'stores/GroupsStore'; +import UiStore from 'stores/UiStore'; + +type Props = { + history: RouterHistory, + ui: UiStore, + groups: GroupsStore, + onSubmit: () => void, +}; + +@observer +class GroupNew extends React.Component { + @observable name: string = ''; + @observable isSaving: boolean; + @observable group: Group; + + handleSubmit = async (ev: SyntheticEvent<>) => { + ev.preventDefault(); + this.isSaving = true; + const group = new Group( + { + name: this.name, + }, + this.props.groups + ); + + try { + this.group = await group.save(); + } catch (err) { + this.props.ui.showToast(err.message); + } finally { + this.isSaving = false; + } + }; + + handleNameChange = (ev: SyntheticInputEvent<*>) => { + this.name = ev.target.value; + }; + + render() { + return ( + +
+ + Groups are for organizing your team. They work best when centered + around a function or a responsibility — Support or Engineering for + example. + + + + + You’ll be able to add people to the group next. + + +
+ + + +
+ ); + } +} + +export default inject('groups', 'ui')(withRouter(GroupNew)); diff --git a/app/scenes/Settings/Groups.js b/app/scenes/Settings/Groups.js new file mode 100644 index 00000000..9d040b74 --- /dev/null +++ b/app/scenes/Settings/Groups.js @@ -0,0 +1,114 @@ +// @flow +import * as React from 'react'; +import invariant from 'invariant'; +import { observable } from 'mobx'; +import { observer, inject } from 'mobx-react'; +import { PlusIcon } from 'outline-icons'; + +import Empty from 'components/Empty'; +import { ListPlaceholder } from 'components/LoadingPlaceholder'; +import Modal from 'components/Modal'; +import Button from 'components/Button'; +import GroupNew from 'scenes/GroupNew'; +import CenteredContent from 'components/CenteredContent'; +import PageTitle from 'components/PageTitle'; +import HelpText from 'components/HelpText'; +import GroupListItem from 'components/GroupListItem'; +import List from 'components/List'; +import Tabs from 'components/Tabs'; +import Tab from 'components/Tab'; +import GroupMenu from 'menus/GroupMenu'; + +import AuthStore from 'stores/AuthStore'; +import GroupsStore from 'stores/GroupsStore'; +import PoliciesStore from 'stores/PoliciesStore'; + +type Props = { + auth: AuthStore, + groups: GroupsStore, + policies: PoliciesStore, + match: Object, +}; + +@observer +class Groups extends React.Component { + @observable newGroupModalOpen: boolean = false; + + componentDidMount() { + this.props.groups.fetchPage({ limit: 100 }); + } + + handleNewGroupModalOpen = () => { + this.newGroupModalOpen = true; + }; + + handleNewGroupModalClose = () => { + this.newGroupModalOpen = false; + }; + + render() { + const { auth, policies, groups } = this.props; + const currentUser = auth.user; + const team = auth.team; + + invariant(currentUser, 'User should exist'); + invariant(team, 'Team should exist'); + + const showLoading = groups.isFetching && !groups.orderedData.length; + const showEmpty = groups.isLoaded && !groups.orderedData.length; + const can = policies.abilities(team.id); + + return ( + + +

Groups

+ + Groups can be used to organize and manage the people on your team. + + + {can.group && ( + + )} + + + + All Groups + + + + + {groups.orderedData.map(group => ( + ( + + )} + showFacepile + /> + ))} + + + {showEmpty && No groups to see here.} + {showLoading && } + + + + +
+ ); + } +} + +export default inject('auth', 'groups', 'policies')(Groups); diff --git a/app/scenes/Settings/Shares.js b/app/scenes/Settings/Shares.js index 82affea9..2dee6c8f 100644 --- a/app/scenes/Settings/Shares.js +++ b/app/scenes/Settings/Shares.js @@ -6,6 +6,7 @@ import SharesStore from 'stores/SharesStore'; import AuthStore from 'stores/AuthStore'; import ShareListItem from './components/ShareListItem'; +import Empty from 'components/Empty'; import List from 'components/List'; import CenteredContent from 'components/CenteredContent'; import Subheading from 'components/Subheading'; @@ -48,15 +49,15 @@ class Shares extends React.Component { sharing in security settings. )} - {hasSharedDocuments && ( - - Shared Documents - - {shares.orderedData.map(share => ( - - ))} - - + Shared Documents + {hasSharedDocuments ? ( + + {shares.orderedData.map(share => ( + + ))} + + ) : ( + No share links, yet. )} ); diff --git a/app/scenes/Settings/components/EventListItem.js b/app/scenes/Settings/components/EventListItem.js index 8d82d573..a230e538 100644 --- a/app/scenes/Settings/components/EventListItem.js +++ b/app/scenes/Settings/components/EventListItem.js @@ -78,19 +78,39 @@ const description = event => { ); case 'users.delete': return 'Deleted their account'; - case 'collections.add_user': + case 'groups.create': return ( - Added {event.data.name} to a private{' '} + Created the group {event.data.name} + + ); + case 'groups.update': + return ( + + Update the group {event.data.name} + + ); + case 'groups.delete': + return ( + + Deleted the group {event.data.name} + + ); + case 'collections.add_user': + case 'collections.add_group': + return ( + + Granted {event.data.name} access to a{' '} collection ); case 'collections.remove_user': + case 'collections.remove_group': return ( - Remove {event.data.name} from a private{' '} + Revoked {event.data.name} access to a{' '} collection diff --git a/app/stores/CollectionGroupMembershipsStore.js b/app/stores/CollectionGroupMembershipsStore.js new file mode 100644 index 00000000..10b58de8 --- /dev/null +++ b/app/stores/CollectionGroupMembershipsStore.js @@ -0,0 +1,83 @@ +// @flow +import invariant from 'invariant'; +import { action, runInAction } from 'mobx'; +import { client } from 'utils/ApiClient'; +import BaseStore from './BaseStore'; +import RootStore from './RootStore'; +import CollectionGroupMembership from 'models/CollectionGroupMembership'; +import type { PaginationParams } from 'types'; + +export default class CollectionGroupMembershipsStore extends BaseStore< + CollectionGroupMembership +> { + actions = ['create', 'delete']; + + constructor(rootStore: RootStore) { + super(rootStore, CollectionGroupMembership); + } + + @action + fetchPage = async (params: ?PaginationParams): Promise<*> => { + this.isFetching = true; + + try { + const res = await client.post(`/collections.group_memberships`, params); + + invariant(res && res.data, 'Data not available'); + + runInAction(`CollectionGroupMembershipsStore#fetchPage`, () => { + res.data.groups.forEach(this.rootStore.groups.add); + res.data.collectionGroupMemberships.forEach(this.add); + this.isLoaded = true; + }); + return res.data.groups; + } finally { + this.isFetching = false; + } + }; + + @action + async create({ + collectionId, + groupId, + permission, + }: { + collectionId: string, + groupId: string, + permission: string, + }) { + const res = await client.post('/collections.add_group', { + id: collectionId, + groupId, + permission, + }); + invariant(res && res.data, 'Membership data should be available'); + + res.data.collectionGroupMemberships.forEach(this.add); + } + + @action + async delete({ + collectionId, + groupId, + }: { + collectionId: string, + groupId: string, + }) { + await client.post('/collections.remove_group', { + id: collectionId, + groupId, + }); + + this.remove(`${groupId}-${collectionId}`); + } + + @action + removeCollectionMemberships = (collectionId: string) => { + this.data.forEach((membership, key) => { + if (key.includes(collectionId)) { + this.remove(key); + } + }); + }; +} diff --git a/app/stores/GroupMembershipsStore.js b/app/stores/GroupMembershipsStore.js new file mode 100644 index 00000000..faa3b2ed --- /dev/null +++ b/app/stores/GroupMembershipsStore.js @@ -0,0 +1,79 @@ +// @flow +import invariant from 'invariant'; +import { action, runInAction } from 'mobx'; +import { filter } from 'lodash'; +import { client } from 'utils/ApiClient'; +import BaseStore from './BaseStore'; +import RootStore from './RootStore'; +import GroupMembership from 'models/GroupMembership'; +import type { PaginationParams } from 'types'; + +export default class GroupMembershipsStore extends BaseStore { + actions = ['create', 'delete']; + + constructor(rootStore: RootStore) { + super(rootStore, GroupMembership); + } + + @action + fetchPage = async (params: ?PaginationParams): Promise<*> => { + this.isFetching = true; + + try { + const res = await client.post(`/groups.memberships`, params); + + invariant(res && res.data, 'Data not available'); + + runInAction(`GroupMembershipsStore#fetchPage`, () => { + res.data.users.forEach(this.rootStore.users.add); + res.data.groupMemberships.forEach(this.add); + this.isLoaded = true; + }); + return res.data.users; + } finally { + this.isFetching = false; + } + }; + + @action + async create({ groupId, userId }: { groupId: string, userId: string }) { + const res = await client.post('/groups.add_user', { + id: groupId, + userId, + }); + invariant(res && res.data, 'Group Membership data should be available'); + + res.data.users.forEach(this.rootStore.users.add); + res.data.groups.forEach(this.rootStore.groups.add); + res.data.groupMemberships.forEach(this.add); + } + + @action + async delete({ groupId, userId }: { groupId: string, userId: string }) { + const res = await client.post('/groups.remove_user', { + id: groupId, + userId, + }); + invariant(res && res.data, 'Group Membership data should be available'); + + this.remove(`${userId}-${groupId}`); + + runInAction(`GroupMembershipsStore#delete`, () => { + res.data.groups.forEach(this.rootStore.groups.add); + this.isLoaded = true; + }); + } + + @action + removeGroupMemberships = (groupId: string) => { + this.data.forEach((_, key) => { + if (key.includes(groupId)) { + this.remove(key); + } + }); + }; + + inGroup = (groupId: string) => { + return filter(this.orderedData, member => member.groupId === groupId); + }; +} diff --git a/app/stores/GroupsStore.js b/app/stores/GroupsStore.js new file mode 100644 index 00000000..445b336b --- /dev/null +++ b/app/stores/GroupsStore.js @@ -0,0 +1,77 @@ +// @flow +import BaseStore from './BaseStore'; +import RootStore from './RootStore'; +import naturalSort from 'shared/utils/naturalSort'; +import Group from 'models/Group'; +import { client } from 'utils/ApiClient'; +import invariant from 'invariant'; +import { filter } from 'lodash'; +import { action, runInAction, computed } from 'mobx'; +import type { PaginationParams } from 'types'; + +export default class GroupsStore extends BaseStore { + constructor(rootStore: RootStore) { + super(rootStore, Group); + } + + @computed + get orderedData(): Group[] { + return naturalSort(Array.from(this.data.values()), 'name'); + } + + @action + fetchPage = async (params: ?PaginationParams): Promise<*> => { + this.isFetching = true; + + try { + const res = await client.post(`/groups.list`, params); + + invariant(res && res.data, 'Data not available'); + + runInAction(`GroupsStore#fetchPage`, () => { + this.addPolicies(res.policies); + res.data.groups.forEach(this.add); + res.data.groupMemberships.forEach(this.rootStore.groupMemberships.add); + this.isLoaded = true; + }); + return res.data.groups; + } finally { + this.isFetching = false; + } + }; + + inCollection = (collectionId: string, query: string) => { + const memberships = filter( + this.rootStore.collectionGroupMemberships.orderedData, + member => member.collectionId === collectionId + ); + const groupIds = memberships.map(member => member.groupId); + const groups = filter(this.orderedData, group => + groupIds.includes(group.id) + ); + + if (!query) return groups; + return queriedGroups(groups, query); + }; + + notInCollection = (collectionId: string, query: string = '') => { + const memberships = filter( + this.rootStore.collectionGroupMemberships.orderedData, + member => member.collectionId === collectionId + ); + const groupIds = memberships.map(member => member.groupId); + const groups = filter( + this.orderedData, + group => !groupIds.includes(group.id) + ); + + if (!query) return groups; + return queriedGroups(groups, query); + }; +} + +function queriedGroups(groups, query) { + return filter(groups, group => + group.name.toLowerCase().match(query.toLowerCase()) + ); +} diff --git a/app/stores/RootStore.js b/app/stores/RootStore.js index be89966f..be84e78a 100644 --- a/app/stores/RootStore.js +++ b/app/stores/RootStore.js @@ -4,6 +4,8 @@ import AuthStore from './AuthStore'; import CollectionsStore from './CollectionsStore'; import DocumentsStore from './DocumentsStore'; import EventsStore from './EventsStore'; +import GroupsStore from './GroupsStore'; +import GroupMembershipsStore from './GroupMembershipsStore'; import IntegrationsStore from './IntegrationsStore'; import MembershipsStore from './MembershipsStore'; import NotificationSettingsStore from './NotificationSettingsStore'; @@ -14,13 +16,17 @@ import SharesStore from './SharesStore'; import UiStore from './UiStore'; import UsersStore from './UsersStore'; import ViewsStore from './ViewsStore'; +import CollectionGroupMembershipsStore from './CollectionGroupMembershipsStore'; export default class RootStore { apiKeys: ApiKeysStore; auth: AuthStore; collections: CollectionsStore; + collectionGroupMemberships: CollectionGroupMembershipsStore; documents: DocumentsStore; events: EventsStore; + groups: GroupsStore; + groupMemberships: GroupMembershipsStore; integrations: IntegrationsStore; memberships: MembershipsStore; notificationSettings: NotificationSettingsStore; @@ -36,8 +42,11 @@ export default class RootStore { this.apiKeys = new ApiKeysStore(this); this.auth = new AuthStore(this); this.collections = new CollectionsStore(this); + this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this); this.documents = new DocumentsStore(this); this.events = new EventsStore(this); + this.groups = new GroupsStore(this); + this.groupMemberships = new GroupMembershipsStore(this); this.integrations = new IntegrationsStore(this); this.memberships = new MembershipsStore(this); this.notificationSettings = new NotificationSettingsStore(this); @@ -52,9 +61,13 @@ export default class RootStore { logout() { this.apiKeys.clear(); + // this.auth omitted for reasons... this.collections.clear(); + this.collectionGroupMemberships.clear(); this.documents.clear(); this.events.clear(); + this.groups.clear(); + this.groupMemberships.clear(); this.integrations.clear(); this.memberships.clear(); this.notificationSettings.clear(); @@ -62,6 +75,7 @@ export default class RootStore { this.policies.clear(); this.revisions.clear(); this.shares.clear(); + // this.ui omitted to keep ui settings between sessions this.users.clear(); this.views.clear(); } diff --git a/app/stores/UsersStore.js b/app/stores/UsersStore.js index 7506d318..12e85031 100644 --- a/app/stores/UsersStore.js +++ b/app/stores/UsersStore.js @@ -82,11 +82,9 @@ export default class UsersStore extends BaseStore { ); const userIds = memberships.map(member => member.userId); const users = filter(this.orderedData, user => !userIds.includes(user.id)); - if (!query) return users; - return filter(users, user => - user.name.toLowerCase().match(query.toLowerCase()) - ); + if (!query) return users; + return queriedUsers(users, query); }; inCollection = (collectionId: string, query: string) => { @@ -98,10 +96,31 @@ export default class UsersStore extends BaseStore { const users = filter(this.orderedData, user => userIds.includes(user.id)); if (!query) return users; + return queriedUsers(users, query); + }; - return filter(users, user => - user.name.toLowerCase().match(query.toLowerCase()) + notInGroup = (groupId: string, query: string = '') => { + const memberships = filter( + this.rootStore.groupMemberships.orderedData, + member => member.groupId === groupId ); + const userIds = memberships.map(member => member.userId); + const users = filter(this.orderedData, user => !userIds.includes(user.id)); + + if (!query) return users; + return queriedUsers(users, query); + }; + + inGroup = (groupId: string, query: string) => { + const groupMemberships = filter( + this.rootStore.groupMemberships.orderedData, + member => member.groupId === groupId + ); + const userIds = groupMemberships.map(member => member.userId); + const users = filter(this.orderedData, user => userIds.includes(user.id)); + + if (!query) return users; + return queriedUsers(users, query); }; actionOnUser = async (action: string, user: User) => { @@ -116,3 +135,9 @@ export default class UsersStore extends BaseStore { }); }; } + +function queriedUsers(users, query) { + return filter(users, user => + user.name.toLowerCase().match(query.toLowerCase()) + ); +} diff --git a/package.json b/package.json index 30a45350..826dab35 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,7 @@ "mobx-react": "^5.4.2", "natural-sort": "^1.0.0", "nodemailer": "^4.4.0", - "outline-icons": "^1.10.0", + "outline-icons": "^1.13.0", "oy-vey": "^0.10.0", "pg": "^6.1.5", "pg-hstore": "2.3.2", diff --git a/server/api/__snapshots__/collections.test.js.snap b/server/api/__snapshots__/collections.test.js.snap index f5f5bb34..01338028 100644 --- a/server/api/__snapshots__/collections.test.js.snap +++ b/server/api/__snapshots__/collections.test.js.snap @@ -1,5 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`#collections.add_group should require group in team 1`] = ` +Object { + "error": "authorization_error", + "message": "Authorization error", + "ok": false, +} +`; + exports[`#collections.add_user should require user in team 1`] = ` Object { "error": "authorization_error", @@ -44,6 +52,15 @@ Object { } `; +exports[`#collections.group_memberships should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + exports[`#collections.info should require authentication 1`] = ` Object { "error": "authentication_required", @@ -71,6 +88,14 @@ Object { } `; +exports[`#collections.remove_group should require group in team 1`] = ` +Object { + "error": "authorization_error", + "message": "Authorization error", + "ok": false, +} +`; + exports[`#collections.remove_user should require user in team 1`] = ` Object { "error": "authorization_error", diff --git a/server/api/__snapshots__/groups.test.js.snap b/server/api/__snapshots__/groups.test.js.snap new file mode 100644 index 00000000..323a53f1 --- /dev/null +++ b/server/api/__snapshots__/groups.test.js.snap @@ -0,0 +1,88 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#groups.add_user should require admin 1`] = ` +Object { + "error": "admin_required", + "message": "An admin role is required to access this resource", + "ok": false, + "status": 403, +} +`; + +exports[`#groups.add_user should require user in team 1`] = ` +Object { + "error": "authorization_error", + "message": "Authorization error", + "ok": false, +} +`; + +exports[`#groups.delete should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#groups.info should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#groups.list should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#groups.memberships should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#groups.remove_user should require admin 1`] = ` +Object { + "error": "admin_required", + "message": "An admin role is required to access this resource", + "ok": false, + "status": 403, +} +`; + +exports[`#groups.remove_user should require user in team 1`] = ` +Object { + "error": "authorization_error", + "message": "Authorization error", + "ok": false, +} +`; + +exports[`#groups.update should require authentication 1`] = ` +Object { + "error": "authentication_required", + "message": "Authentication required", + "ok": false, + "status": 401, +} +`; + +exports[`#groups.update when user is admin fails with validation error when name already taken 1`] = ` +Object { + "error": "", + "message": "The name of this group is already in use (isUniqueNameInTeam)", + "ok": false, +} +`; diff --git a/server/api/collections.js b/server/api/collections.js index 7d6b100b..4cc584d0 100644 --- a/server/api/collections.js +++ b/server/api/collections.js @@ -9,8 +9,18 @@ import { presentUser, presentPolicies, presentMembership, + presentGroup, + presentCollectionGroupMembership, } from '../presenters'; -import { Collection, CollectionUser, Team, Event, User } from '../models'; +import { + Collection, + CollectionUser, + CollectionGroup, + Team, + Event, + User, + Group, +} from '../models'; import { ValidationError } from '../errors'; import { exportCollections } from '../logistics'; import { archiveCollection, archiveCollections } from '../utils/zip'; @@ -79,6 +89,148 @@ router.post('collections.info', auth(), async ctx => { }; }); +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 collection = await Collection.scope({ + method: ['withMembership', ctx.state.user.id], + }).findByPk(id); + authorize(ctx.state.user, 'update', collection); + + const group = await Group.findByPk(groupId); + authorize(ctx.state.user, 'read', group); + + let membership = await CollectionGroup.findOne({ + where: { + collectionId: id, + groupId, + }, + }); + + if (!membership) { + membership = await CollectionGroup.create({ + collectionId: id, + groupId, + permission, + createdById: ctx.state.user.id, + }); + } else if (permission) { + membership.permission = permission; + await membership.save(); + } + + await Event.create({ + name: 'collections.add_group', + collectionId: collection.id, + teamId: collection.teamId, + actorId: ctx.state.user.id, + data: { name: group.name, groupId }, + ip: ctx.request.ip, + }); + + ctx.body = { + data: { + collectionGroupMemberships: [ + presentCollectionGroupMembership(membership), + ], + }, + }; +}); + +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 collection = await Collection.scope({ + method: ['withMembership', ctx.state.user.id], + }).findByPk(id); + authorize(ctx.state.user, 'update', collection); + + const group = await Group.findByPk(groupId); + authorize(ctx.state.user, 'read', group); + + await collection.removeGroup(group); + + await Event.create({ + name: 'collections.remove_group', + collectionId: collection.id, + teamId: collection.teamId, + actorId: ctx.state.user.id, + data: { name: group.name, groupId }, + ip: ctx.request.ip, + }); + + ctx.body = { + success: true, + }; +}); + +router.post( + 'collections.group_memberships', + auth(), + pagination(), + async ctx => { + const { id, query, permission } = ctx.body; + ctx.assertUuid(id, 'id is required'); + + const user = ctx.state.user; + const collection = await Collection.scope({ + method: ['withMembership', user.id], + }).findByPk(id); + + authorize(user, 'read', collection); + + let where = { + collectionId: id, + }; + + let groupWhere; + + if (query) { + groupWhere = { + name: { + [Op.iLike]: `%${query}%`, + }, + }; + } + + if (permission) { + where = { + ...where, + permission, + }; + } + + const memberships = await CollectionGroup.findAll({ + where, + order: [['createdAt', 'DESC']], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + include: [ + { + model: Group, + as: 'group', + where: groupWhere, + required: true, + }, + ], + }); + + ctx.body = { + pagination: ctx.state.pagination, + data: { + collectionGroupMemberships: memberships.map( + presentCollectionGroupMembership + ), + groups: memberships.map(membership => presentGroup(membership.group)), + }, + }; + } +); + router.post('collections.add_user', auth(), async ctx => { const { id, userId, permission = 'read_write' } = ctx.body; ctx.assertUuid(id, 'id is required'); @@ -302,9 +454,11 @@ router.post('collections.update', auth(), async ctx => { } const user = ctx.state.user; + const collection = await Collection.scope({ method: ['withMembership', user.id], }).findByPk(id); + authorize(user, 'update', collection); // we're making this collection private right now, ensure that the current @@ -328,6 +482,7 @@ router.post('collections.update', auth(), async ctx => { collection.description = description; collection.color = color; collection.private = isPrivate; + await collection.save(); await Event.create({ @@ -342,11 +497,7 @@ router.post('collections.update', auth(), async ctx => { // must reload to update collection membership for correct policy calculation // if the privacy level has changed. Otherwise skip this query for speed. if (isPrivacyChanged) { - await collection.reload({ - scope: { - method: ['withMembership', user.id], - }, - }); + await collection.reload(); } ctx.body = { @@ -385,6 +536,7 @@ router.post('collections.delete', auth(), async ctx => { const collection = await Collection.scope({ method: ['withMembership', user.id], }).findByPk(id); + authorize(user, 'delete', collection); const total = await Collection.count(); diff --git a/server/api/collections.test.js b/server/api/collections.test.js index 91f532bd..e9e5ab01 100644 --- a/server/api/collections.test.js +++ b/server/api/collections.test.js @@ -2,8 +2,8 @@ import TestServer from 'fetch-test-server'; import app from '../app'; import { flushdb, seed } from '../test/support'; -import { buildUser, buildCollection } from '../test/factories'; -import { Collection, CollectionUser } from '../models'; +import { buildUser, buildGroup, buildCollection } from '../test/factories'; +import { Collection, CollectionUser, CollectionGroup } from '../models'; const server = new TestServer(app.callback()); beforeEach(flushdb); @@ -32,7 +32,7 @@ describe('#collections.list', async () => { expect(body.policies[0].abilities.read).toEqual(true); }); - it('should not return private collections not a member of', async () => { + it('should not return private collections actor is not a member of', async () => { const { user, collection } = await seed(); await buildCollection({ private: true, @@ -48,13 +48,50 @@ describe('#collections.list', async () => { expect(body.data[0].id).toEqual(collection.id); }); - it('should return private collections member of', async () => { - const { user } = await seed(); + it('should return private collections actor is a member of', async () => { + const user = await buildUser(); await buildCollection({ private: true, teamId: user.teamId, userId: user.id, }); + await buildCollection({ + private: true, + teamId: user.teamId, + userId: user.id, + }); + + const res = await server.post('/api/collections.list', { + body: { token: user.getJwtToken() }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.length).toEqual(2); + expect(body.policies.length).toEqual(2); + expect(body.policies[0].abilities.read).toEqual(true); + }); + + it('should return private collections actor is a group-member of', async () => { + const user = await buildUser(); + await buildCollection({ + private: true, + teamId: user.teamId, + userId: user.id, + }); + + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + + const group = await buildGroup({ teamId: user.teamId }); + await group.addUser(user, { through: { createdById: user.id } }); + + await collection.addGroup(group, { + through: { permission: 'read', createdById: user.id }, + }); + const res = await server.post('/api/collections.list', { body: { token: user.getJwtToken() }, }); @@ -81,7 +118,7 @@ describe('#collections.export', async () => { expect(res.status).toEqual(403); }); - it('should allow export of private collection', async () => { + it('should allow export of private collection when the actor is a member', async () => { const { user, collection } = await seed(); collection.private = true; await collection.save(); @@ -100,6 +137,27 @@ describe('#collections.export', async () => { expect(res.status).toEqual(200); }); + it('should allow export of private collection when the actor is a group member', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + + const group = await buildGroup({ teamId: user.teamId }); + await group.addUser(user, { through: { createdById: user.id } }); + + await collection.addGroup(group, { + through: { permission: 'read', createdById: user.id }, + }); + + const res = await server.post('/api/collections.export', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + + expect(res.status).toEqual(200); + }); + it('should require authentication', async () => { const res = await server.post('/api/collections.export'); const body = await res.json(); @@ -221,6 +279,145 @@ describe('#collections.add_user', async () => { }); }); +describe('#collections.add_group', async () => { + it('should add group to collection', async () => { + const user = await buildUser({ isAdmin: true }); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + private: true, + }); + const group = await buildGroup({ teamId: user.teamId }); + const res = await server.post('/api/collections.add_group', { + body: { + token: user.getJwtToken(), + id: collection.id, + groupId: group.id, + }, + }); + + const groups = await collection.getGroups(); + expect(groups.length).toEqual(1); + expect(res.status).toEqual(200); + }); + + it('should require group in team', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + private: true, + }); + const group = await buildGroup(); + const res = await server.post('/api/collections.add_group', { + body: { + token: user.getJwtToken(), + id: collection.id, + groupId: group.id, + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/collections.add_group'); + expect(res.status).toEqual(401); + }); + + it('should require authorization', async () => { + const collection = await buildCollection(); + const user = await buildUser(); + const group = await buildGroup({ teamId: user.teamId }); + const res = await server.post('/api/collections.add_group', { + body: { + token: user.getJwtToken(), + id: collection.id, + groupId: group.id, + }, + }); + expect(res.status).toEqual(403); + }); +}); + +describe('#collections.remove_group', async () => { + it('should remove group from collection', async () => { + const user = await buildUser({ isAdmin: true }); + const collection = await buildCollection({ + teamId: user.teamId, + userId: user.id, + private: true, + }); + const group = await buildGroup({ teamId: user.teamId }); + + await server.post('/api/collections.add_group', { + body: { + token: user.getJwtToken(), + id: collection.id, + groupId: group.id, + }, + }); + + let users = await collection.getGroups(); + expect(users.length).toEqual(1); + + const res = await server.post('/api/collections.remove_group', { + body: { + token: user.getJwtToken(), + id: collection.id, + groupId: group.id, + }, + }); + + users = await collection.getGroups(); + expect(res.status).toEqual(200); + expect(users.length).toEqual(0); + }); + + it('should require group in team', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + teamId: user.teamId, + private: true, + }); + const group = await buildGroup(); + const res = await server.post('/api/collections.remove_group', { + body: { + token: user.getJwtToken(), + id: collection.id, + groupId: group.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/collections.remove_group'); + + expect(res.status).toEqual(401); + }); + + it('should require authorization', async () => { + const { collection } = await seed(); + const user = await buildUser(); + const group = await buildGroup({ teamId: user.teamId }); + + const res = await server.post('/api/collections.remove_group', { + body: { + token: user.getJwtToken(), + id: collection.id, + groupId: group.id, + }, + }); + expect(res.status).toEqual(403); + }); +}); + describe('#collections.remove_user', async () => { it('should remove user from collection', async () => { const user = await buildUser(); @@ -334,6 +531,155 @@ describe('#collections.users', async () => { }); }); +describe('#collections.group_memberships', async () => { + it('should return groups in private collection', async () => { + const user = await buildUser(); + const group = await buildGroup({ teamId: user.teamId }); + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + + await CollectionUser.create({ + createdById: user.id, + collectionId: collection.id, + userId: user.id, + permission: 'read_write', + }); + + await CollectionGroup.create({ + createdById: user.id, + collectionId: collection.id, + groupId: group.id, + permission: 'read_write', + }); + + const res = await server.post('/api/collections.group_memberships', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + 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.collectionGroupMemberships.length).toEqual(1); + expect(body.data.collectionGroupMemberships[0].permission).toEqual( + 'read_write' + ); + }); + + it('should allow filtering groups in collection by name', async () => { + const user = await buildUser(); + const group = await buildGroup({ name: 'will find', teamId: user.teamId }); + const group2 = await buildGroup({ name: 'wont find', teamId: user.teamId }); + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + + await CollectionUser.create({ + createdById: user.id, + collectionId: collection.id, + userId: user.id, + permission: 'read_write', + }); + + await CollectionGroup.create({ + createdById: user.id, + collectionId: collection.id, + groupId: group.id, + permission: 'read_write', + }); + + await CollectionGroup.create({ + createdById: user.id, + collectionId: collection.id, + groupId: group2.id, + permission: 'read_write', + }); + + const res = await server.post('/api/collections.group_memberships', { + body: { + token: user.getJwtToken(), + id: collection.id, + query: 'will', + }, + }); + + 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); + }); + + it('should allow filtering groups in collection by permission', async () => { + const user = await buildUser(); + const group = await buildGroup({ teamId: user.teamId }); + const group2 = await buildGroup({ teamId: user.teamId }); + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + + await CollectionUser.create({ + createdById: user.id, + collectionId: collection.id, + userId: user.id, + permission: 'read_write', + }); + + await CollectionGroup.create({ + createdById: user.id, + collectionId: collection.id, + groupId: group.id, + permission: 'read_write', + }); + + await CollectionGroup.create({ + createdById: user.id, + collectionId: collection.id, + groupId: group2.id, + permission: 'maintainer', + }); + + const res = await server.post('/api/collections.group_memberships', { + body: { + token: user.getJwtToken(), + id: collection.id, + permission: 'maintainer', + }, + }); + + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.groups.length).toEqual(1); + expect(body.data.groups[0].id).toEqual(group2.id); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/collections.group_memberships'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + + const res = await server.post('/api/collections.group_memberships', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + expect(res.status).toEqual(403); + }); +}); + describe('#collections.memberships', async () => { it('should return members in private collection', async () => { const { collection, user } = await seed(); @@ -641,6 +987,29 @@ describe('#collections.update', async () => { expect(body.policies.length).toBe(1); }); + it('allows editing by read-write collection group user', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + + const group = await buildGroup({ teamId: user.teamId }); + await group.addUser(user, { through: { createdById: user.id } }); + + await collection.addGroup(group, { + through: { permission: 'read_write', createdById: user.id }, + }); + + const res = await server.post('/api/collections.update', { + body: { token: user.getJwtToken(), id: collection.id, name: 'Test' }, + }); + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.name).toBe('Test'); + expect(body.policies.length).toBe(1); + }); + it('does not allow editing by read-only collection user', async () => { const { user, collection } = await seed(); collection.private = true; @@ -704,4 +1073,30 @@ describe('#collections.delete', async () => { expect(res.status).toEqual(200); expect(body.success).toBe(true); }); + + it('allows deleting by read-write collection group user', async () => { + const user = await buildUser(); + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + await buildCollection({ + teamId: user.teamId, + }); + + const group = await buildGroup({ teamId: user.teamId }); + await group.addUser(user, { through: { createdById: user.id } }); + + await collection.addGroup(group, { + through: { permission: 'read_write', createdById: user.id }, + }); + + const res = await server.post('/api/collections.delete', { + body: { token: user.getJwtToken(), id: collection.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.success).toBe(true); + }); }); diff --git a/server/api/documents.js b/server/api/documents.js index cae59faa..e9403018 100644 --- a/server/api/documents.js +++ b/server/api/documents.js @@ -121,6 +121,7 @@ router.post('documents.pinned', auth(), pagination(), async ctx => { const collection = await Collection.scope({ method: ['withMembership', user.id], }).findByPk(collectionId); + authorize(user, 'read', collection); const starredScope = { method: ['withStarred', user.id] }; diff --git a/server/api/documents.test.js b/server/api/documents.test.js index 3000dba1..7f132d74 100644 --- a/server/api/documents.test.js +++ b/server/api/documents.test.js @@ -47,9 +47,12 @@ describe('#documents.info', async () => { }); it('should not return published document in collection not a member of', async () => { - const { user, document, collection } = await seed(); - collection.private = true; - await collection.save(); + const user = await buildUser(); + const collection = await buildCollection({ + private: true, + teamId: user.teamId, + }); + const document = await buildDocument({ collectionId: collection.id }); const res = await server.post('/api/documents.info', { body: { token: user.getJwtToken(), id: document.id }, @@ -381,13 +384,16 @@ describe('#documents.pinned', async () => { }); it('should not return pinned documents in private collections not a member of', async () => { - const { user, collection } = await seed(); - collection.private = true; - await collection.save(); + const collection = await buildCollection({ + private: true, + }); + + const user = await buildUser({ teamId: collection.teamId }); const res = await server.post('/api/documents.pinned', { body: { token: user.getJwtToken(), collectionId: collection.id }, }); + expect(res.status).toEqual(403); }); diff --git a/server/api/groups.js b/server/api/groups.js new file mode 100644 index 00000000..a64e2141 --- /dev/null +++ b/server/api/groups.js @@ -0,0 +1,291 @@ +// @flow +import Router from 'koa-router'; +import auth from '../middlewares/authentication'; +import pagination from './middlewares/pagination'; +import { Op } from '../sequelize'; +import { MAX_AVATAR_DISPLAY } from '../../shared/constants'; + +import { + presentGroup, + presentPolicies, + presentUser, + presentGroupMembership, +} from '../presenters'; +import { User, Event, Group, GroupUser } from '../models'; +import policy from '../policies'; + +const { authorize } = policy; +const router = new Router(); + +router.post('groups.list', auth(), pagination(), async ctx => { + const user = ctx.state.user; + + let groups = await Group.findAll({ + where: { + teamId: user.teamId, + }, + order: [['updatedAt', 'DESC']], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }); + + if (!user.isAdmin) { + groups = groups.filter( + group => group.groupMemberships.filter(gm => gm.userId === user.id).length + ); + } + + ctx.body = { + pagination: ctx.state.pagination, + data: { + groups: groups.map(presentGroup), + groupMemberships: groups + .map(g => g.groupMemberships.slice(0, MAX_AVATAR_DISPLAY)) + .flat() + .map(presentGroupMembership), + }, + policies: presentPolicies(user, groups), + }; +}); + +router.post('groups.info', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertUuid(id, 'id is required'); + + const user = ctx.state.user; + const group = await Group.findByPk(id); + authorize(user, 'read', group); + + ctx.body = { + data: presentGroup(group), + policies: presentPolicies(user, [group]), + }; +}); + +router.post('groups.create', auth(), async ctx => { + const { name } = ctx.body; + ctx.assertPresent(name, 'name is required'); + + const user = ctx.state.user; + + authorize(user, 'create', Group); + let group = await Group.create({ + name, + teamId: user.teamId, + createdById: user.id, + }); + + // reload to get default scope + group = await Group.findByPk(group.id); + + await Event.create({ + name: 'groups.create', + actorId: user.id, + teamId: user.teamId, + modelId: group.id, + data: { name: group.name }, + ip: ctx.request.ip, + }); + + ctx.body = { + data: presentGroup(group), + policies: presentPolicies(user, [group]), + }; +}); + +router.post('groups.update', auth(), async ctx => { + const { id, name } = ctx.body; + ctx.assertPresent(name, 'name is required'); + ctx.assertUuid(id, 'id is required'); + + const user = ctx.state.user; + const group = await Group.findByPk(id); + + authorize(user, 'update', group); + + group.name = name; + + if (group.changed()) { + await group.save(); + await Event.create({ + name: 'groups.update', + teamId: user.teamId, + actorId: user.id, + modelId: group.id, + data: { name }, + ip: ctx.request.ip, + }); + } + + ctx.body = { + data: presentGroup(group), + policies: presentPolicies(user, [group]), + }; +}); + +router.post('groups.delete', auth(), async ctx => { + const { id } = ctx.body; + ctx.assertUuid(id, 'id is required'); + + const { user } = ctx.state; + const group = await Group.findByPk(id); + + authorize(user, 'delete', group); + await group.destroy(); + + await Event.create({ + name: 'groups.delete', + actorId: user.id, + modelId: group.id, + teamId: group.teamId, + data: { name: group.name }, + ip: ctx.request.ip, + }); + + ctx.body = { + success: true, + }; +}); + +router.post('groups.memberships', auth(), pagination(), async ctx => { + const { id, query, permission } = ctx.body; + ctx.assertUuid(id, 'id is required'); + + const user = ctx.state.user; + const group = await Group.findByPk(id); + + authorize(user, 'read', group); + + let where = { + groupId: id, + }; + + let userWhere; + + if (query) { + userWhere = { + name: { + [Op.iLike]: `%${query}%`, + }, + }; + } + + if (permission) { + where = { + ...where, + permission, + }; + } + + const memberships = await GroupUser.findAll({ + where, + order: [['createdAt', 'DESC']], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + include: [ + { + model: User, + as: 'user', + where: userWhere, + required: true, + }, + ], + }); + + ctx.body = { + pagination: ctx.state.pagination, + data: { + groupMemberships: memberships.map(presentGroupMembership), + users: memberships.map(membership => presentUser(membership.user)), + }, + }; +}); + +router.post('groups.add_user', auth(), async ctx => { + const { id, userId } = ctx.body; + ctx.assertUuid(id, 'id is required'); + ctx.assertUuid(userId, 'userId is required'); + + const user = await User.findByPk(userId); + authorize(ctx.state.user, 'read', user); + + let group = await Group.findByPk(id); + authorize(ctx.state.user, 'update', group); + + let membership = await GroupUser.findOne({ + where: { + groupId: id, + userId, + }, + }); + + if (!membership) { + await group.addUser(user, { + through: { createdById: ctx.state.user.id }, + }); + + // reload to get default scope + membership = await GroupUser.findOne({ + where: { + groupId: id, + userId, + }, + }); + + // reload to get default scope + group = await Group.findByPk(id); + + await Event.create({ + name: 'groups.add_user', + userId, + teamId: user.teamId, + modelId: group.id, + actorId: ctx.state.user.id, + data: { name: user.name }, + ip: ctx.request.ip, + }); + } + + ctx.body = { + data: { + users: [presentUser(user)], + groupMemberships: [presentGroupMembership(membership)], + groups: [presentGroup(group)], + }, + }; +}); + +router.post('groups.remove_user', auth(), async ctx => { + const { id, userId } = ctx.body; + ctx.assertUuid(id, 'id is required'); + ctx.assertUuid(userId, 'userId is required'); + + let group = await Group.findByPk(id); + authorize(ctx.state.user, 'update', group); + + const user = await User.findByPk(userId); + authorize(ctx.state.user, 'read', user); + + await group.removeUser(user); + + await Event.create({ + name: 'groups.remove_user', + userId, + modelId: group.id, + teamId: user.teamId, + actorId: ctx.state.user.id, + data: { name: user.name }, + ip: ctx.request.ip, + }); + + // reload to get default scope + group = await Group.findByPk(id); + + ctx.body = { + data: { + groups: [presentGroup(group)], + }, + }; +}); + +export default router; diff --git a/server/api/groups.test.js b/server/api/groups.test.js new file mode 100644 index 00000000..6070eefc --- /dev/null +++ b/server/api/groups.test.js @@ -0,0 +1,460 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import TestServer from 'fetch-test-server'; +import app from '../app'; +import { flushdb } from '../test/support'; +import { buildUser, buildGroup } from '../test/factories'; +import { Event } from '../models'; + +const server = new TestServer(app.callback()); + +beforeEach(flushdb); +afterAll(server.close); + +describe('#groups.create', async () => { + it('should create a group', async () => { + const name = 'hello I am a group'; + const user = await buildUser({ isAdmin: true }); + + const res = await server.post('/api/groups.create', { + body: { token: user.getJwtToken(), name }, + }); + + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.name).toEqual(name); + }); +}); + +describe('#groups.update', async () => { + it('should require authentication', async () => { + const group = await buildGroup(); + const res = await server.post('/api/groups.update', { + body: { id: group.id, name: 'Test' }, + }); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require admin', async () => { + const group = await buildGroup(); + const user = await buildUser(); + const res = await server.post('/api/groups.update', { + body: { token: user.getJwtToken(), id: group.id, name: 'Test' }, + }); + expect(res.status).toEqual(403); + }); + + it('should require authorization', async () => { + const group = await buildGroup(); + const user = await buildUser({ isAdmin: true }); + + const res = await server.post('/api/groups.update', { + body: { token: user.getJwtToken(), id: group.id, name: 'Test' }, + }); + expect(res.status).toEqual(403); + }); + + describe('when user is admin', async () => { + let user, group; + + beforeEach(async () => { + user = await buildUser({ isAdmin: true }); + group = await buildGroup({ teamId: user.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' }, + }); + + const events = await Event.findAll(); + expect(events.length).toEqual(1); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.name).toBe('Test'); + }); + + 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 }, + }); + + const events = await Event.findAll(); + expect(events.length).toEqual(0); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.data.name).toBe(group.name); + }); + + it('fails with validation error when name already taken', async () => { + await buildGroup({ + teamId: user.teamId, + name: 'test', + }); + + const res = await server.post('/api/groups.update', { + body: { + token: user.getJwtToken(), + id: group.id, + name: 'TEST', + }, + }); + + const body = await res.json(); + expect(res.status).toEqual(400); + expect(body).toMatchSnapshot(); + }); + }); +}); + +describe('#groups.list', async () => { + it('should require authentication', async () => { + const res = await server.post('/api/groups.list'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should return groups with memberships preloaded', async () => { + const user = await buildUser(); + const group = await buildGroup({ teamId: user.teamId }); + + await group.addUser(user, { through: { createdById: user.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(user.id); + + expect(body.policies.length).toEqual(1); + expect(body.policies[0].abilities.read).toEqual(true); + }); +}); + +describe('#groups.info', async () => { + it('should return group if admin', async () => { + const user = await buildUser({ isAdmin: true }); + const group = await buildGroup({ teamId: user.teamId }); + + 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 }); + await group.addUser(user, { through: { createdById: user.id } }); + + 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 not return group if non-member, non-admin', async () => { + const user = await buildUser(); + const group = await buildGroup({ teamId: user.teamId }); + + const res = await server.post('/api/groups.info', { + body: { token: user.getJwtToken(), id: group.id }, + }); + + expect(res.status).toEqual(403); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/groups.info'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const user = await buildUser(); + const group = await buildGroup(); + const res = await server.post('/api/groups.info', { + body: { token: user.getJwtToken(), id: group.id }, + }); + expect(res.status).toEqual(403); + }); +}); + +describe('#groups.delete', async () => { + it('should require authentication', async () => { + const group = await buildGroup(); + const res = await server.post('/api/groups.delete', { + body: { id: group.id }, + }); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require admin', async () => { + const group = await buildGroup(); + const user = await buildUser(); + const res = await server.post('/api/groups.delete', { + body: { token: user.getJwtToken(), id: group.id }, + }); + expect(res.status).toEqual(403); + }); + + it('should require authorization', async () => { + const group = await buildGroup(); + const user = await buildUser({ isAdmin: true }); + + const res = await server.post('/api/groups.delete', { + body: { token: user.getJwtToken(), id: group.id }, + }); + expect(res.status).toEqual(403); + }); + + it('allows admin to delete a group', async () => { + const user = await buildUser({ isAdmin: true }); + const group = await buildGroup({ teamId: user.teamId }); + + const res = await server.post('/api/groups.delete', { + body: { token: user.getJwtToken(), id: group.id }, + }); + + const body = await res.json(); + expect(res.status).toEqual(200); + expect(body.success).toEqual(true); + }); +}); + +describe('#groups.memberships', async () => { + it('should return members in a group', async () => { + const user = await buildUser(); + const group = await buildGroup({ teamId: user.teamId }); + + await group.addUser(user, { through: { createdById: user.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(user.id); + expect(body.data.groupMemberships.length).toEqual(1); + expect(body.data.groupMemberships[0].user.id).toEqual(user.id); + }); + + it('should allow filtering members in group by name', async () => { + const user = await buildUser(); + const user2 = await buildUser({ name: "Won't find" }); + const group = await buildGroup({ teamId: user.teamId }); + + await group.addUser(user, { through: { createdById: user.id } }); + await group.addUser(user2, { through: { createdById: user.id } }); + + const res = await server.post('/api/groups.memberships', { + body: { + token: user.getJwtToken(), + id: group.id, + query: user.name.slice(0, 3), + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(200); + expect(body.data.users.length).toEqual(1); + expect(body.data.users[0].id).toEqual(user.id); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/groups.memberships'); + const body = await res.json(); + + expect(res.status).toEqual(401); + expect(body).toMatchSnapshot(); + }); + + it('should require authorization', async () => { + const user = await buildUser(); + const group = await buildGroup(); + + const res = await server.post('/api/groups.memberships', { + body: { token: user.getJwtToken(), id: group.id }, + }); + expect(res.status).toEqual(403); + }); +}); + +describe('#groups.add_user', async () => { + it('should add user to group', async () => { + const user = await buildUser({ isAdmin: true }); + const group = await buildGroup({ + teamId: user.teamId, + }); + + const res = await server.post('/api/groups.add_user', { + body: { + token: user.getJwtToken(), + id: group.id, + userId: user.id, + }, + }); + + const users = await group.getUsers(); + expect(res.status).toEqual(200); + expect(users.length).toEqual(1); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/groups.add_user'); + expect(res.status).toEqual(401); + }); + + it('should require user in team', async () => { + const user = await buildUser({ isAdmin: true }); + const group = await buildGroup({ + teamId: user.teamId, + }); + const anotherUser = await buildUser(); + + const res = await server.post('/api/groups.add_user', { + body: { + token: user.getJwtToken(), + id: group.id, + userId: anotherUser.id, + }, + }); + + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); + + it('should require admin', async () => { + const user = await buildUser(); + const group = await buildGroup({ + teamId: user.teamId, + }); + const anotherUser = await buildUser({ teamId: user.teamId }); + + const res = await server.post('/api/groups.add_user', { + body: { + token: user.getJwtToken(), + id: group.id, + userId: anotherUser.id, + }, + }); + + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); +}); + +describe('#groups.remove_user', async () => { + it('should remove user from group', async () => { + const user = await buildUser({ isAdmin: true }); + const group = await buildGroup({ + teamId: user.teamId, + }); + + await server.post('/api/groups.add_user', { + body: { + token: user.getJwtToken(), + id: group.id, + userId: user.id, + }, + }); + + const users = await group.getUsers(); + expect(users.length).toEqual(1); + + const res = await server.post('/api/groups.remove_user', { + body: { + token: user.getJwtToken(), + id: group.id, + userId: user.id, + }, + }); + + const users1 = await group.getUsers(); + expect(res.status).toEqual(200); + expect(users1.length).toEqual(0); + }); + + it('should require authentication', async () => { + const res = await server.post('/api/groups.remove_user'); + + expect(res.status).toEqual(401); + }); + + it('should require user in team', async () => { + const user = await buildUser({ isAdmin: true }); + const group = await buildGroup({ + teamId: user.teamId, + }); + const anotherUser = await buildUser(); + + const res = await server.post('/api/groups.remove_user', { + body: { + token: user.getJwtToken(), + id: group.id, + userId: anotherUser.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); + + it('should require admin', async () => { + const user = await buildUser(); + const group = await buildGroup({ + teamId: user.teamId, + }); + const anotherUser = await buildUser({ + teamId: user.teamId, + }); + + const res = await server.post('/api/groups.remove_user', { + body: { + token: user.getJwtToken(), + id: group.id, + userId: anotherUser.id, + }, + }); + const body = await res.json(); + + expect(res.status).toEqual(403); + expect(body).toMatchSnapshot(); + }); +}); diff --git a/server/api/index.js b/server/api/index.js index a5210d76..82e34d4b 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -12,6 +12,7 @@ import views from './views'; import hooks from './hooks'; import apiKeys from './apiKeys'; import shares from './shares'; +import groups from './groups'; import team from './team'; import integrations from './integrations'; import notificationSettings from './notificationSettings'; @@ -51,6 +52,8 @@ router.use('/', integrations.routes()); router.use('/', notificationSettings.routes()); router.use('/', attachments.routes()); router.use('/', utils.routes()); +router.use('/', groups.routes()); + router.post('*', ctx => { ctx.throw(new NotFoundError('Endpoint not found')); }); diff --git a/server/api/users.js b/server/api/users.js index f7e770fc..aaf3cf86 100644 --- a/server/api/users.js +++ b/server/api/users.js @@ -9,7 +9,6 @@ import { publicS3Endpoint, makeCredential, } from '../utils/s3'; -import { ValidationError } from '../errors'; import { Attachment, Event, User, Team } from '../models'; import auth from '../middlewares/authentication'; import pagination from './middlewares/pagination'; @@ -177,11 +176,7 @@ router.post('users.demote', auth(), async ctx => { authorize(ctx.state.user, 'demote', user); const team = await Team.findByPk(teamId); - try { - await team.removeAdmin(user); - } catch (err) { - throw new ValidationError(err.message); - } + await team.removeAdmin(user); await Event.create({ name: 'users.demote', @@ -207,11 +202,7 @@ router.post('users.suspend', auth(), async ctx => { authorize(ctx.state.user, 'suspend', user); const team = await Team.findByPk(teamId); - try { - await team.suspendUser(user, admin); - } catch (err) { - throw new ValidationError(err.message); - } + await team.suspendUser(user, admin); await Event.create({ name: 'users.suspend', @@ -278,12 +269,7 @@ router.post('users.delete', auth(), async ctx => { if (id) user = await User.findByPk(id); authorize(ctx.state.user, 'delete', user); - try { - await user.destroy(); - } catch (err) { - throw new ValidationError(err.message); - } - + await user.destroy(); await Event.create({ name: 'users.delete', actorId: user.id, diff --git a/server/events.js b/server/events.js index ef666777..1238213d 100644 --- a/server/events.js +++ b/server/events.js @@ -78,6 +78,33 @@ export type CollectionEvent = collectionId: string, teamId: string, actorId: string, + } + | { + name: 'collections.add_group' | 'collections.remove_group', + collectionId: string, + teamId: string, + actorId: string, + data: { name: string, groupId: string }, + ip: string, + }; + +export type GroupEvent = + | { + name: 'groups.create' | 'groups.delete' | 'groups.update', + actorId: string, + modelId: string, + teamId: string, + data: { name: string }, + ip: string, + } + | { + name: 'groups.add_user' | 'groups.remove_user', + actorId: string, + userId: string, + modelId: string, + teamId: string, + data: { name: string }, + ip: string, }; export type IntegrationEvent = { @@ -91,7 +118,8 @@ export type Event = | UserEvent | DocumentEvent | CollectionEvent - | IntegrationEvent; + | IntegrationEvent + | GroupEvent; const globalEventsQueue = createQueue('global events'); const serviceEventsQueue = createQueue('service events'); diff --git a/server/migrations/20191211044318-create-groups.js b/server/migrations/20191211044318-create-groups.js new file mode 100644 index 00000000..4181e362 --- /dev/null +++ b/server/migrations/20191211044318-create-groups.js @@ -0,0 +1,50 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("groups", { + id: { + type: Sequelize.UUID, + allowNull: false, + primaryKey: true + }, + name: { + type: Sequelize.STRING, + allowNull: false + }, + teamId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "teams" + } + }, + createdById: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "users" + } + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true + } + }); + + await queryInterface.addIndex("groups", ["teamId"]); + await queryInterface.addIndex("groups", ["deletedAt"]); + }, + + down: async (queryInterface, Sequelize) => { + return queryInterface.dropTable("groups"); + } +}; diff --git a/server/migrations/20191211044319-create-group-users.js b/server/migrations/20191211044319-create-group-users.js new file mode 100644 index 00000000..d1c3f6d1 --- /dev/null +++ b/server/migrations/20191211044319-create-group-users.js @@ -0,0 +1,49 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("group_users", { + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "users" + } + }, + groupId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "groups" + } + }, + createdById: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "users" + } + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true + } + }); + + await queryInterface.addIndex("group_users", ["groupId", "userId"]); + await queryInterface.addIndex("group_users", ["userId"]); + await queryInterface.addIndex("group_users", ["deletedAt"]); + }, + + down: async (queryInterface, Sequelize) => { + return queryInterface.dropTable("group_users"); + } +}; diff --git a/server/migrations/20200122083721-create-collection-groups.js b/server/migrations/20200122083721-create-collection-groups.js new file mode 100644 index 00000000..de9cd9ef --- /dev/null +++ b/server/migrations/20200122083721-create-collection-groups.js @@ -0,0 +1,53 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("collection_groups", { + collectionId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "collections" + } + }, + groupId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "groups" + } + }, + createdById: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "users" + } + }, + permission: { + type: Sequelize.STRING, + allowNull: false + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true + } + }); + + await queryInterface.addIndex("collection_groups", ["collectionId", "groupId"]); + await queryInterface.addIndex("collection_groups", ["groupId"]); + await queryInterface.addIndex("collection_groups", ["deletedAt"]); + }, + + down: async (queryInterface, Sequelize) => { + return queryInterface.dropTable("collection_groups"); + } +}; diff --git a/server/models/ApiKey.js b/server/models/ApiKey.js index 47d17096..f0c0580b 100644 --- a/server/models/ApiKey.js +++ b/server/models/ApiKey.js @@ -12,6 +12,7 @@ const ApiKey = sequelize.define( }, name: DataTypes.STRING, secret: { type: DataTypes.STRING, unique: true }, + // TODO: remove this, as it's redundant with associate below userId: { type: DataTypes.UUID, allowNull: false, diff --git a/server/models/Collection.js b/server/models/Collection.js index 929cadd4..56cb055b 100644 --- a/server/models/Collection.js +++ b/server/models/Collection.js @@ -1,5 +1,5 @@ // @flow -import { find, remove } from 'lodash'; +import { find, concat, remove, uniq } from 'lodash'; import slug from 'slug'; import randomstring from 'randomstring'; import { DataTypes, sequelize } from '../sequelize'; @@ -59,11 +59,21 @@ Collection.associate = models => { foreignKey: 'collectionId', onDelete: 'cascade', }); + Collection.hasMany(models.CollectionGroup, { + as: 'collectionGroupMemberships', + foreignKey: 'collectionId', + onDelete: 'cascade', + }); Collection.belongsToMany(models.User, { as: 'users', through: models.CollectionUser, foreignKey: 'collectionId', }); + Collection.belongsToMany(models.Group, { + as: 'groups', + through: models.CollectionGroup, + foreignKey: 'collectionId', + }); Collection.belongsTo(models.User, { as: 'user', foreignKey: 'creatorId', @@ -79,8 +89,66 @@ Collection.associate = models => { where: { userId }, required: false, }, + { + model: models.CollectionGroup, + as: 'collectionGroupMemberships', + required: false, + + // use of "separate" property: sequelize breaks when there are + // nested "includes" with alternating values for "required" + // see https://github.com/sequelize/sequelize/issues/9869 + separate: true, + + // include for groups that are members of this collection, + // of which userId is a member of, resulting in: + // CollectionGroup [inner join] Group [inner join] GroupUser [where] userId + include: { + model: models.Group, + as: 'group', + required: true, + include: { + model: models.GroupUser, + as: 'groupMemberships', + required: true, + where: { userId }, + }, + }, + }, ], })); + Collection.addScope('withAllMemberships', { + include: [ + { + model: models.CollectionUser, + as: 'memberships', + required: false, + }, + { + model: models.CollectionGroup, + as: 'collectionGroupMemberships', + required: false, + + // use of "separate" property: sequelize breaks when there are + // nested "includes" with alternating values for "required" + // see https://github.com/sequelize/sequelize/issues/9869 + separate: true, + + // include for groups that are members of this collection, + // of which userId is a member of, resulting in: + // CollectionGroup [inner join] Group [inner join] GroupUser [where] userId + include: { + model: models.Group, + as: 'group', + required: true, + include: { + model: models.GroupUser, + as: 'groupMemberships', + required: true, + }, + }, + }, + ], + }); }; Collection.addHook('afterDestroy', async (model: Collection) => { @@ -107,6 +175,26 @@ Collection.addHook('afterCreate', (model: Collection, options) => { } }); +// Class methods + +// get all the membership relationshps a user could have with the collection +Collection.membershipUserIds = async (collectionId: string) => { + const collection = await Collection.scope('withAllMemberships').findByPk( + collectionId + ); + + const groupMemberships = collection.collectionGroupMemberships + .map(cgm => cgm.group.groupMemberships) + .flat(); + + const membershipUserIds = concat( + groupMemberships, + collection.memberships + ).map(membership => membership.userId); + + return uniq(membershipUserIds); +}; + // Instance methods Collection.prototype.addDocumentToStructure = async function( diff --git a/server/models/Collection.test.js b/server/models/Collection.test.js index c36c87a3..23a68758 100644 --- a/server/models/Collection.test.js +++ b/server/models/Collection.test.js @@ -1,6 +1,12 @@ /* eslint-disable flowtype/require-valid-file-annotation */ import { flushdb, seed } from '../test/support'; import { Collection, Document } from '../models'; +import { + buildUser, + buildGroup, + buildCollection, + buildTeam, +} from '../test/factories'; import uuid from 'uuid'; beforeEach(flushdb); @@ -229,3 +235,44 @@ describe('#removeDocument', () => { expect(collectionDocuments.count).toBe(1); }); }); + +describe('#membershipUserIds', () => { + test('should return collection and group memberships', async () => { + const team = await buildTeam(); + const teamId = team.id; + + // Make 6 users + const users = await Promise.all( + Array(6) + .fill() + .map(() => { + return buildUser({ teamId }); + }) + ); + + const collection = await buildCollection({ + userId: users[0].id, + private: true, + teamId, + }); + + const group1 = await buildGroup({ teamId }); + const group2 = await buildGroup({ teamId }); + + const createdById = users[0].id; + + await group1.addUser(users[0], { through: { createdById } }); + await group1.addUser(users[1], { through: { createdById } }); + await group2.addUser(users[2], { through: { createdById } }); + await group2.addUser(users[3], { through: { createdById } }); + + await collection.addUser(users[4], { through: { createdById } }); + await collection.addUser(users[5], { through: { createdById } }); + + await collection.addGroup(group1, { through: { createdById } }); + await collection.addGroup(group2, { through: { createdById } }); + + const membershipUserIds = await Collection.membershipUserIds(collection.id); + expect(membershipUserIds.length).toBe(6); + }); +}); diff --git a/server/models/CollectionGroup.js b/server/models/CollectionGroup.js new file mode 100644 index 00000000..5988fc97 --- /dev/null +++ b/server/models/CollectionGroup.js @@ -0,0 +1,38 @@ +// @flow +import { DataTypes, sequelize } from '../sequelize'; + +const CollectionGroup = sequelize.define( + 'collection_group', + { + permission: { + type: DataTypes.STRING, + defaultValue: 'read_write', + validate: { + isIn: [['read', 'read_write', 'maintainer']], + }, + }, + }, + { + timestamps: true, + paranoid: true, + } +); + +CollectionGroup.associate = models => { + CollectionGroup.belongsTo(models.Collection, { + as: 'collection', + foreignKey: 'collectionId', + primary: true, + }); + CollectionGroup.belongsTo(models.Group, { + as: 'group', + foreignKey: 'groupId', + primary: true, + }); + CollectionGroup.belongsTo(models.User, { + as: 'createdBy', + foreignKey: 'createdById', + }); +}; + +export default CollectionGroup; diff --git a/server/models/Document.js b/server/models/Document.js index 6bb9837a..49121467 100644 --- a/server/models/Document.js +++ b/server/models/Document.js @@ -172,16 +172,10 @@ Document.associate = models => { return { include: [ { - model: models.Collection, + model: models.Collection.scope({ + method: ['withMembership', userId], + }), as: 'collection', - include: [ - { - model: models.CollectionUser, - as: 'memberships', - where: { userId }, - required: false, - }, - ], }, ], }; @@ -269,7 +263,7 @@ Document.searchForTeam = async ( "collectionId" IN(:collectionIds) AND "deletedAt" IS NULL AND "publishedAt" IS NOT NULL - ORDER BY + ORDER BY "searchRanking" DESC, "updatedAt" DESC LIMIT :limit @@ -356,8 +350,8 @@ Document.searchForUser = async ( options.includeDrafts ? '("publishedAt" IS NOT NULL OR "createdById" = :userId)' : '"publishedAt" IS NOT NULL' - } - ORDER BY + } + ORDER BY "searchRanking" DESC, "updatedAt" DESC LIMIT :limit diff --git a/server/models/Event.js b/server/models/Event.js index bd9cd255..eaf43181 100644 --- a/server/models/Event.js +++ b/server/models/Event.js @@ -81,10 +81,15 @@ Event.AUDIT_EVENTS = [ 'documents.delete', 'shares.create', 'shares.revoke', + 'groups.create', + 'groups.update', + 'groups.delete', 'collections.create', 'collections.update', 'collections.add_user', 'collections.remove_user', + 'collections.add_group', + 'collections.remove_group', 'collections.delete', ]; diff --git a/server/models/Group.js b/server/models/Group.js new file mode 100644 index 00000000..2dccd10c --- /dev/null +++ b/server/models/Group.js @@ -0,0 +1,83 @@ +// @flow +import { Op, DataTypes, sequelize } from '../sequelize'; +import { CollectionGroup, GroupUser } from '../models'; + +const Group = sequelize.define( + 'group', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + teamId: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + }, + { + timestamps: true, + paranoid: true, + validate: { + isUniqueNameInTeam: async function() { + const foundItem = await Group.findOne({ + where: { + teamId: this.teamId, + name: { [Op.iLike]: this.name }, + id: { [Op.not]: this.id }, + }, + }); + if (foundItem) { + throw new Error('The name of this group is already in use'); + } + }, + }, + } +); + +Group.associate = models => { + Group.hasMany(models.GroupUser, { + as: 'groupMemberships', + foreignKey: 'groupId', + }); + Group.hasMany(models.CollectionGroup, { + as: 'collectionGroupMemberships', + foreignKey: 'groupId', + }); + Group.belongsTo(models.Team, { + as: 'team', + foreignKey: 'teamId', + }); + Group.belongsTo(models.User, { + as: 'createdBy', + foreignKey: 'createdById', + }); + Group.belongsToMany(models.User, { + as: 'users', + through: models.GroupUser, + foreignKey: 'groupId', + }); + Group.addScope('defaultScope', { + include: [ + { + association: 'groupMemberships', + required: false, + }, + ], + order: [['name', 'ASC']], + }); +}; + +// Cascade deletes to group and collection relations +Group.addHook('afterDestroy', async (group, options) => { + if (!group.deletedAt) return; + + await GroupUser.destroy({ where: { groupId: group.id } }); + await CollectionGroup.destroy({ where: { groupId: group.id } }); +}); + +export default Group; diff --git a/server/models/Group.test.js b/server/models/Group.test.js new file mode 100644 index 00000000..8f98d075 --- /dev/null +++ b/server/models/Group.test.js @@ -0,0 +1,48 @@ +/* eslint-disable flowtype/require-valid-file-annotation */ +import { flushdb } from '../test/support'; +import { CollectionGroup, GroupUser } from '../models'; +import { buildUser, buildGroup, buildCollection } from '../test/factories'; + +beforeEach(flushdb); +beforeEach(jest.resetAllMocks); + +describe('afterDestroy hook', () => { + test('should destroy associated group and collection join relations', async () => { + const group = await buildGroup(); + const teamId = group.teamId; + + const user1 = await buildUser({ teamId }); + const user2 = await buildUser({ teamId }); + + const collection1 = await buildCollection({ + private: true, + teamId, + }); + const collection2 = await buildCollection({ + private: true, + teamId, + }); + + const createdById = user1.id; + + await group.addUser(user1, { through: { createdById } }); + await group.addUser(user2, { through: { createdById } }); + + await collection1.addGroup(group, { through: { createdById } }); + await collection2.addGroup(group, { through: { createdById } }); + + let collectionGroupCount = await CollectionGroup.count(); + let groupUserCount = await GroupUser.count(); + + expect(collectionGroupCount).toBe(2); + expect(groupUserCount).toBe(2); + + await group.destroy(); + + collectionGroupCount = await CollectionGroup.count(); + groupUserCount = await GroupUser.count(); + + expect(collectionGroupCount).toBe(0); + expect(groupUserCount).toBe(0); + }); +}); diff --git a/server/models/GroupUser.js b/server/models/GroupUser.js new file mode 100644 index 00000000..68da219c --- /dev/null +++ b/server/models/GroupUser.js @@ -0,0 +1,33 @@ +// @flow +import { sequelize } from '../sequelize'; + +const GroupUser = sequelize.define( + 'group_user', + {}, + { + timestamps: true, + paranoid: true, + } +); + +GroupUser.associate = models => { + GroupUser.belongsTo(models.Group, { + as: 'group', + foreignKey: 'groupId', + primary: true, + }); + GroupUser.belongsTo(models.User, { + as: 'user', + foreignKey: 'userId', + primary: true, + }); + GroupUser.belongsTo(models.User, { + as: 'createdBy', + foreignKey: 'createdById', + }); + GroupUser.addScope('defaultScope', { + include: [{ association: 'user' }], + }); +}; + +export default GroupUser; diff --git a/server/models/Team.js b/server/models/Team.js index 0dc8699f..f4c356f9 100644 --- a/server/models/Team.js +++ b/server/models/Team.js @@ -11,6 +11,7 @@ import { RESERVED_SUBDOMAINS, } from '../../shared/utils/domains'; import parseTitle from '../../shared/utils/parseTitle'; +import { ValidationError } from '../errors'; import Collection from './Collection'; import Document from './Document'; @@ -181,13 +182,13 @@ Team.prototype.removeAdmin = async function(user: User) { if (res.count >= 1) { return user.update({ isAdmin: false }); } else { - throw new Error('At least one admin is required'); + throw new ValidationError('At least one admin is required'); } }; Team.prototype.suspendUser = async function(user: User, admin: User) { if (user.id === admin.id) - throw new Error('Unable to suspend the current user'); + throw new ValidationError('Unable to suspend the current user'); return user.update({ suspendedById: admin.id, suspendedAt: new Date(), diff --git a/server/models/User.js b/server/models/User.js index 5069c026..d2afaf48 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -3,6 +3,7 @@ import crypto from 'crypto'; import uuid from 'uuid'; import JWT from 'jsonwebtoken'; import subMinutes from 'date-fns/sub_minutes'; +import { ValidationError } from '../errors'; import { DataTypes, sequelize, encryptedFields } from '../sequelize'; import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3'; import { sendEmail } from '../mailer'; @@ -71,22 +72,22 @@ User.associate = models => { // Instance methods User.prototype.collectionIds = async function(paranoid: boolean = true) { - let models = await Collection.findAll({ + const collectionStubs = await Collection.scope({ + method: ['withMembership', this.id], + }).findAll({ attributes: ['id', 'private'], where: { teamId: this.teamId }, - include: [ - { - model: User, - as: 'users', - where: { id: this.id }, - required: false, - }, - ], paranoid, }); - // Filter collections that are private and don't have an association - return models.filter(c => !c.private || c.users.length).map(c => c.id); + return collectionStubs + .filter( + c => + !c.private || + c.memberships.length > 0 || + c.collectionGroupMemberships.length > 0 + ) + .map(c => c.id); }; User.prototype.updateActiveAt = function(ip) { @@ -186,7 +187,7 @@ const checkLastAdmin = async model => { const adminCount = await User.count({ where: { isAdmin: true, teamId } }); if (userCount > 1 && adminCount <= 1) { - throw new Error( + throw new ValidationError( 'Cannot delete account as only admin. Please transfer admin permissions to another user and try again.' ); } diff --git a/server/models/index.js b/server/models/index.js index 6c4e86c6..cf5c27d1 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -5,9 +5,12 @@ import Authentication from './Authentication'; import Backlink from './Backlink'; import Collection from './Collection'; import CollectionUser from './CollectionUser'; +import CollectionGroup from './CollectionGroup'; import Document from './Document'; import Event from './Event'; import Integration from './Integration'; +import Group from './Group'; +import GroupUser from './GroupUser'; import Notification from './Notification'; import NotificationSetting from './NotificationSetting'; import Revision from './Revision'; @@ -23,9 +26,12 @@ const models = { Authentication, Backlink, Collection, + CollectionGroup, CollectionUser, Document, Event, + Group, + GroupUser, Integration, Notification, NotificationSetting, @@ -50,9 +56,12 @@ export { Authentication, Backlink, Collection, + CollectionGroup, CollectionUser, Document, Event, + Group, + GroupUser, Integration, Notification, NotificationSetting, diff --git a/server/policies/collection.js b/server/policies/collection.js index 9a1d1765..e9d1052a 100644 --- a/server/policies/collection.js +++ b/server/policies/collection.js @@ -1,6 +1,7 @@ // @flow import invariant from 'invariant'; import policy from './policy'; +import { concat, some } from 'lodash'; import { Collection, User } from '../models'; import { AdminRequiredError } from '../errors'; @@ -11,11 +12,20 @@ allow(User, 'create', Collection); allow(User, ['read', 'export'], Collection, (user, collection) => { if (!collection || user.teamId !== collection.teamId) return false; - if ( - collection.private && - (!collection.memberships || !collection.memberships.length) - ) { - 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', 'read_write', 'maintainer'].includes(m.permission) + ); } return true; @@ -29,10 +39,14 @@ allow(User, ['publish', 'update'], Collection, (user, collection) => { collection.memberships, 'membership should be preloaded, did you forget withMembership scope?' ); - if (!collection.memberships.length) return false; - return ['read_write', 'maintainer'].includes( - collection.memberships[0].permission + const allMemberships = concat( + collection.memberships, + collection.collectionGroupMemberships + ); + + return some(allMemberships, m => + ['read_write', 'maintainer'].includes(m.permission) ); } @@ -47,15 +61,14 @@ allow(User, 'delete', Collection, (user, collection) => { collection.memberships, 'membership should be preloaded, did you forget withMembership scope?' ); - if (!collection.memberships.length) return false; + const allMemberships = concat( + collection.memberships, + collection.collectionGroupMemberships + ); - if ( - !['read_write', 'maintainer'].includes( - collection.memberships[0].permission - ) - ) { - return false; - } + return some(allMemberships, m => + ['read_write', 'maintainer'].includes(m.permission) + ); } if (user.isAdmin) return true; diff --git a/server/policies/group.js b/server/policies/group.js new file mode 100644 index 00000000..58e23066 --- /dev/null +++ b/server/policies/group.js @@ -0,0 +1,26 @@ +// @flow +import policy from './policy'; +import { Group, User } from '../models'; +import { AdminRequiredError } from '../errors'; + +const { allow } = policy; + +allow(User, ['create'], Group, actor => { + if (actor.isAdmin) return true; + throw new AdminRequiredError(); +}); + +allow(User, ['update', 'delete'], Group, (actor, group) => { + if (!group || actor.teamId !== group.teamId) return false; + if (actor.isAdmin) return true; + throw new AdminRequiredError(); +}); + +allow(User, ['read'], Group, (actor, group) => { + if (!group || actor.teamId !== group.teamId) return false; + if (actor.isAdmin) return true; + if (group.groupMemberships.filter(gm => gm.userId === actor.id).length) { + return true; + } + return false; +}); diff --git a/server/policies/index.js b/server/policies/index.js index 7a89e6aa..8f390a4e 100644 --- a/server/policies/index.js +++ b/server/policies/index.js @@ -1,5 +1,5 @@ // @flow -import { Team, User, Collection, Document } from '../models'; +import { Team, User, Collection, Document, Group } from '../models'; import policy from './policy'; import './apiKey'; import './collection'; @@ -9,6 +9,7 @@ import './notificationSetting'; import './share'; import './user'; import './team'; +import './group'; const { can, abilities } = policy; @@ -17,13 +18,13 @@ type Policy = { }; /* -* Given a user and a model – output an object which describes the actions the +* Given a user and a model – output an object which describes the actions the * user may take against the model. This serialized policy is used for testing * and sent in API responses to allow clients to adjust which UI is displayed. */ export function serialize( model: User, - target: Team | Collection | Document + target: Team | Collection | Document | Group ): Policy { let output = {}; diff --git a/server/policies/team.js b/server/policies/team.js index b0e6d5d9..c8c34d57 100644 --- a/server/policies/team.js +++ b/server/policies/team.js @@ -22,6 +22,12 @@ allow(User, 'invite', Team, user => { return false; }); +// ??? policy for creating new groups, I don't know how to do this other than on the team level +allow(User, 'group', Team, user => { + if (user.isAdmin) return true; + throw new AdminRequiredError(); +}); + allow(User, ['update', 'export'], Team, (user, team) => { if (!team || user.teamId !== team.id) return false; if (user.isAdmin) return true; diff --git a/server/presenters/collectionGroupMembership.js b/server/presenters/collectionGroupMembership.js new file mode 100644 index 00000000..abefe773 --- /dev/null +++ b/server/presenters/collectionGroupMembership.js @@ -0,0 +1,18 @@ +// @flow +import { CollectionGroup } from '../models'; + +type Membership = { + id: string, + groupId: string, + collectionId: string, + permission: string, +}; + +export default (membership: CollectionGroup): Membership => { + return { + id: `${membership.groupId}-${membership.collectionId}`, + groupId: membership.groupId, + collectionId: membership.collectionId, + permission: membership.permission, + }; +}; diff --git a/server/presenters/group.js b/server/presenters/group.js new file mode 100644 index 00000000..4bda1baa --- /dev/null +++ b/server/presenters/group.js @@ -0,0 +1,11 @@ +// @flow +import { Group } from '../models'; + +export default function present(group: Group) { + return { + id: group.id, + name: group.name, + memberCount: group.groupMemberships.length, + updatedAt: group.updatedAt, + }; +} diff --git a/server/presenters/groupMembership.js b/server/presenters/groupMembership.js new file mode 100644 index 00000000..ac1b038e --- /dev/null +++ b/server/presenters/groupMembership.js @@ -0,0 +1,18 @@ +// @flow +import { GroupUser } from '../models'; +import { presentUser } from '.'; + +type GroupMembership = { + id: string, + userId: string, + groupId: string, +}; + +export default (membership: GroupUser): GroupMembership => { + return { + id: `${membership.userId}-${membership.groupId}`, + userId: membership.userId, + groupId: membership.groupId, + user: presentUser(membership.user), + }; +}; diff --git a/server/presenters/index.js b/server/presenters/index.js index 7bf172be..370214ac 100644 --- a/server/presenters/index.js +++ b/server/presenters/index.js @@ -13,6 +13,9 @@ import presentMembership from './membership'; import presentNotificationSetting from './notificationSetting'; import presentSlackAttachment from './slackAttachment'; import presentPolicies from './policy'; +import presentGroup from './group'; +import presentGroupMembership from './groupMembership'; +import presentCollectionGroupMembership from './collectionGroupMembership'; export { presentUser, @@ -24,9 +27,12 @@ export { presentApiKey, presentShare, presentTeam, + presentGroup, presentIntegration, presentMembership, presentNotificationSetting, presentSlackAttachment, presentPolicies, + presentGroupMembership, + presentCollectionGroupMembership, }; diff --git a/server/sequelize.js b/server/sequelize.js index f0f7fd5e..fbcf4975 100644 --- a/server/sequelize.js +++ b/server/sequelize.js @@ -12,6 +12,7 @@ export const DataTypes = Sequelize; export const Op = Sequelize.Op; export const sequelize = new Sequelize(process.env.DATABASE_URL, { + // logging: console.log, logging: debug('sql'), typeValidation: true, }); diff --git a/server/services/websockets.js b/server/services/websockets.js index 27ff46c7..b35484f2 100644 --- a/server/services/websockets.js +++ b/server/services/websockets.js @@ -1,7 +1,15 @@ // @flow import type { Event } from '../events'; -import { Document, Collection } from '../models'; +import { + Document, + Collection, + Group, + CollectionGroup, + GroupUser, +} from '../models'; import { socketio } from '../'; +import { Op } from '../sequelize'; +import subHours from 'date-fns/sub_hours'; export default class Websockets { async on(event: Event) { @@ -206,19 +214,261 @@ export default class Websockets { }); } case 'collections.remove_user': { - // let everyone with access to the collection know a user was removed - socketio.to(`collection-${event.collectionId}`).emit(event.name, { - event: event.name, - userId: event.userId, - collectionId: event.collectionId, + const membershipUserIds = await Collection.membershipUserIds( + event.collectionId + ); + + if (membershipUserIds.includes(event.userId)) { + // Even though we just removed a user from the collection + // the user still has access through some means + // treat this like an add, so that the client re-syncs policies + socketio.to(`user-${event.userId}`).emit('collections.add_user', { + event: 'collections.add_user', + userId: event.userId, + collectionId: event.collectionId, + }); + } else { + // let everyone with access to the collection know a user was removed + socketio + .to(`collection-${event.collectionId}`) + .emit('collections.remove_user', { + event: event.name, + userId: event.userId, + collectionId: event.collectionId, + }); + + // tell any user clients to disconnect from the websocket channel for the collection + socketio.to(`user-${event.userId}`).emit('leave', { + event: event.name, + collectionId: event.collectionId, + }); + } + return; + } + case 'collections.add_group': { + const group = await Group.findByPk(event.data.groupId); + + // the users being added are not yet in the websocket channel for the collection + // so they need to be notified separately + for (const groupMembership of group.groupMemberships) { + socketio + .to(`user-${groupMembership.userId}`) + .emit('collections.add_user', { + event: event.name, + userId: groupMembership.userId, + collectionId: event.collectionId, + }); + + // tell any user clients to connect to the websocket channel for the collection + socketio.to(`user-${groupMembership.userId}`).emit('join', { + event: event.name, + collectionId: event.collectionId, + }); + } + return; + } + case 'collections.remove_group': { + const group = await Group.findByPk(event.data.groupId); + const membershipUserIds = await Collection.membershipUserIds( + event.collectionId + ); + + for (const groupMembership of group.groupMemberships) { + if (membershipUserIds.includes(groupMembership.userId)) { + // the user still has access through some means... + // treat this like an add, so that the client re-syncs policies + socketio + .to(`user-${groupMembership.userId}`) + .emit('collections.add_user', { + event: event.name, + userId: groupMembership.userId, + collectionId: event.collectionId, + }); + } else { + // let users in the channel know they were removed + socketio + .to(`user-${groupMembership.userId}`) + .emit('collections.remove_user', { + event: event.name, + userId: groupMembership.userId, + collectionId: event.collectionId, + }); + + // tell any user clients to disconnect to the websocket channel for the collection + socketio.to(`user-${groupMembership.userId}`).emit('leave', { + event: event.name, + collectionId: event.collectionId, + }); + } + } + return; + } + case 'groups.create': + case 'groups.update': { + const group = await Group.findByPk(event.modelId, { + paranoid: false, }); - // tell any user clients to disconnect from the websocket channel for the collection - return socketio.to(`user-${event.userId}`).emit('leave', { + return socketio.to(`team-${group.teamId}`).emit('entities', { event: event.name, - collectionId: event.collectionId, + groupIds: [ + { + id: group.id, + updatedAt: group.updatedAt, + }, + ], }); } + case 'groups.add_user': { + // do an add user for every collection that the group is a part of + const collectionGroupMemberships = await CollectionGroup.findAll({ + where: { groupId: event.modelId }, + }); + + for (const collectionGroup of collectionGroupMemberships) { + // the user being added isn't yet in the websocket channel for the collection + // so they need to be notified separately + socketio.to(`user-${event.userId}`).emit('collections.add_user', { + event: event.name, + userId: event.userId, + collectionId: collectionGroup.collectionId, + }); + + // let everyone with access to the collection know a user was added + socketio + .to(`collection-${collectionGroup.collectionId}`) + .emit('collections.add_user', { + event: event.name, + userId: event.userId, + collectionId: collectionGroup.collectionId, + }); + + // tell any user clients to connect to the websocket channel for the collection + return socketio.to(`user-${event.userId}`).emit('join', { + event: event.name, + collectionId: collectionGroup.collectionId, + }); + } + return; + } + case 'groups.remove_user': { + const collectionGroupMemberships = await CollectionGroup.findAll({ + where: { groupId: event.modelId }, + }); + + for (const collectionGroup of collectionGroupMemberships) { + // if the user has any memberships remaining on the collection + // we need to emit add instead of remove + const collection = await Collection.scope({ + method: ['withMembership', event.userId], + }).findByPk(collectionGroup.collectionId); + + const hasMemberships = + collection.memberships.length > 0 || + collection.collectionGroupMemberships.length > 0; + + if (hasMemberships) { + // the user still has access through some means... + // treat this like an add, so that the client re-syncs policies + socketio.to(`user-${event.userId}`).emit('collections.add_user', { + event: event.name, + userId: event.userId, + collectionId: collectionGroup.collectionId, + }); + } else { + // let everyone with access to the collection know a user was removed + socketio + .to(`collection-${collectionGroup.collectionId}`) + .emit('collections.remove_user', { + event: event.name, + userId: event.userId, + collectionId: collectionGroup.collectionId, + }); + + // tell any user clients to disconnect from the websocket channel for the collection + socketio.to(`user-${event.userId}`).emit('leave', { + event: event.name, + collectionId: collectionGroup.collectionId, + }); + } + } + return; + } + case 'groups.delete': { + const group = await Group.findByPk(event.modelId, { + paranoid: false, + }); + + socketio.to(`team-${group.teamId}`).emit('entities', { + event: event.name, + groupIds: [ + { + id: group.id, + updatedAt: group.updatedAt, + }, + ], + }); + + // we the users and collection relations that were just severed as a result of the group deletion + // since there are cascading deletes, we approximate this by looking for the recently deleted + // items in the GroupUser and CollectionGroup tables + const groupUsers = await GroupUser.findAll({ + paranoid: false, + where: { + groupId: event.modelId, + deletedAt: { + [Op.gt]: subHours(new Date(), 1), + }, + }, + }); + + const collectionGroupMemberships = await CollectionGroup.findAll({ + paranoid: false, + where: { + groupId: event.modelId, + deletedAt: { + [Op.gt]: subHours(new Date(), 1), + }, + }, + }); + + for (const collectionGroup of collectionGroupMemberships) { + const membershipUserIds = await Collection.membershipUserIds( + collectionGroup.collectionId + ); + + for (const groupUser of groupUsers) { + if (membershipUserIds.includes(groupUser.userId)) { + // the user still has access through some means... + // treat this like an add, so that the client re-syncs policies + socketio + .to(`user-${groupUser.userId}`) + .emit('collections.add_user', { + event: event.name, + userId: groupUser.userId, + collectionId: collectionGroup.collectionId, + }); + } else { + // let everyone with access to the collection know a user was removed + socketio + .to(`collection-${collectionGroup.collectionId}`) + .emit('collections.remove_user', { + event: event.name, + userId: groupUser.userId, + collectionId: collectionGroup.collectionId, + }); + + // tell any user clients to disconnect from the websocket channel for the collection + socketio.to(`user-${groupUser.userId}`).emit('leave', { + event: event.name, + collectionId: collectionGroup.collectionId, + }); + } + } + } + return; + } + default: } } diff --git a/server/test/factories.js b/server/test/factories.js index 1f8acb6c..f0493224 100644 --- a/server/test/factories.js +++ b/server/test/factories.js @@ -6,6 +6,8 @@ import { Event, Document, Collection, + Group, + GroupUser, Attachment, } from '../models'; import uuid from 'uuid'; @@ -72,12 +74,12 @@ export async function buildCollection(overrides: Object = {}) { } if (!overrides.userId) { - const user = await buildUser(); + const user = await buildUser({ teamId: overrides.teamId }); overrides.userId = user.id; } return Collection.create({ - name: 'Test Collection', + name: `Test Collection ${count}`, description: 'Test collection description', creatorId: overrides.userId, type: 'atlas', @@ -85,6 +87,45 @@ export async function buildCollection(overrides: Object = {}) { }); } +export async function buildGroup(overrides: Object = {}) { + count++; + + if (!overrides.teamId) { + const team = await buildTeam(); + overrides.teamId = team.id; + } + + if (!overrides.userId) { + const user = await buildUser({ teamId: overrides.teamId }); + overrides.userId = user.id; + } + + return Group.create({ + name: `Test Group ${count}`, + createdById: overrides.userId, + ...overrides, + }); +} + +export async function buildGroupUser(overrides: Object = {}) { + count++; + + if (!overrides.teamId) { + const team = await buildTeam(); + overrides.teamId = team.id; + } + + if (!overrides.userId) { + const user = await buildUser({ teamId: overrides.teamId }); + overrides.userId = user.id; + } + + return GroupUser.create({ + createdById: overrides.userId, + ...overrides, + }); +} + export async function buildDocument(overrides: Object = {}) { count++; diff --git a/shared/constants.js b/shared/constants.js index 01a80645..c4cca7aa 100644 --- a/shared/constants.js +++ b/shared/constants.js @@ -1,3 +1,4 @@ // @flow export const USER_PRESENCE_INTERVAL = 5000; +export const MAX_AVATAR_DISPLAY = 6; diff --git a/shared/utils/routeHelpers.js b/shared/utils/routeHelpers.js index 29d34db9..c65afe85 100644 --- a/shared/utils/routeHelpers.js +++ b/shared/utils/routeHelpers.js @@ -72,3 +72,7 @@ export function signin(service: string = 'slack'): string { export function settings(): string { return `/settings`; } + +export function groupSettings(): string { + return `/settings/groups`; +} diff --git a/yarn.lock b/yarn.lock index b5d61ad2..de715785 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7160,6 +7160,11 @@ outline-icons@^1.10.0: resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.10.0.tgz#3c8e6957429e2b04c9d0fc72fe72e473813ce5bd" integrity sha512-1o3SnjzawEIh+QkZ6GHxPckuV+Tk5m5R2tjGY0CtosF3YA7JbgQ2jQrZdQsrqLzLa1j07f1bTEbAjGdbnunLpg== +outline-icons@^1.13.0: + version "1.13.0" + resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.13.0.tgz#61ea3824a2ec23ea91bb636aa7e1ba6ebf0c2da6" + integrity sha512-kG/3ugK8lqAz0b4n8yiuw3XENqoIlTguYQ/NiU5A4ccbOV16HESBVau6ftwIoLbHbio6vEMdRNRwD4GQFtUDFw== + oy-vey@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/oy-vey/-/oy-vey-0.10.0.tgz#16160f837f0ea3d0340adfc2377ba93d1ed9ce76"