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 <tom.moor@gmail.com> * 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 <tom.moor@gmail.com> * Update server/migrations/20191211044318-create-groups.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update server/api/groups.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update server/api/groups.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/menus/CollectionMenu.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update server/models/Group.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * minor fixes * Update app/scenes/CollectionMembers/AddGroupsToCollection.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/menus/GroupMenu.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/menus/GroupMenu.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/menus/GroupMenu.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/scenes/Collection.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/scenes/CollectionMembers/CollectionMembers.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/scenes/GroupNew.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/scenes/GroupNew.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/scenes/Settings/Groups.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update server/api/documents.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * Update app/scenes/CollectionMembers/components/CollectionGroupMemberListItem.js Co-Authored-By: Tom Moor <tom.moor@gmail.com> * 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 <tom.moor@gmail.com>
This commit is contained in:
parent
6c451a34d4
commit
142303b3de
|
@ -39,11 +39,11 @@ class Avatar extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const AvatarWrapper = styled.span`
|
const AvatarWrapper = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const IconWrapper = styled.span`
|
const IconWrapper = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -2px;
|
bottom: -2px;
|
||||||
|
@ -56,6 +56,7 @@ const IconWrapper = styled.span`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CircleImg = styled.img`
|
const CircleImg = styled.img`
|
||||||
|
display: block;
|
||||||
width: ${props => props.size}px;
|
width: ${props => props.size}px;
|
||||||
height: ${props => props.size}px;
|
height: ${props => props.size}px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
|
@ -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<Props> {
|
||||||
|
@observable isOpen: boolean = false;
|
||||||
|
|
||||||
|
handleOpenProfile = () => {
|
||||||
|
this.isOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCloseProfile = () => {
|
||||||
|
this.isOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
lastViewedAt,
|
||||||
|
isPresent,
|
||||||
|
isEditing,
|
||||||
|
isCurrentUser,
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<Tooltip
|
||||||
|
tooltip={
|
||||||
|
<Centered>
|
||||||
|
<strong>{user.name}</strong> {isCurrentUser && '(You)'}
|
||||||
|
<br />
|
||||||
|
{isPresent
|
||||||
|
? isEditing ? 'currently editing' : 'currently viewing'
|
||||||
|
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
|
||||||
|
</Centered>
|
||||||
|
}
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<AvatarWrapper isPresent={isPresent}>
|
||||||
|
<Avatar
|
||||||
|
src={user.avatarUrl}
|
||||||
|
onClick={this.handleOpenProfile}
|
||||||
|
size={32}
|
||||||
|
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
||||||
|
/>
|
||||||
|
</AvatarWrapper>
|
||||||
|
</Tooltip>
|
||||||
|
<UserProfile
|
||||||
|
user={user}
|
||||||
|
isOpen={this.isOpen}
|
||||||
|
onRequestClose={this.handleCloseProfile}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
|
@ -1,3 +1,6 @@
|
||||||
// @flow
|
// @flow
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
|
import AvatarWithPresence from './AvatarWithPresence';
|
||||||
|
|
||||||
|
export { AvatarWithPresence };
|
||||||
export default Avatar;
|
export default Avatar;
|
||||||
|
|
|
@ -1,22 +1,14 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { observable } from 'mobx';
|
|
||||||
import { observer, inject } from 'mobx-react';
|
import { observer, inject } from 'mobx-react';
|
||||||
import { sortBy } from 'lodash';
|
import { sortBy, keyBy } from 'lodash';
|
||||||
import styled, { withTheme } from 'styled-components';
|
import { MAX_AVATAR_DISPLAY } from 'shared/constants';
|
||||||
import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
|
|
||||||
|
|
||||||
import Flex from 'shared/components/Flex';
|
import { AvatarWithPresence } from 'components/Avatar';
|
||||||
import Avatar from 'components/Avatar';
|
import Facepile from 'components/Facepile';
|
||||||
import Tooltip from 'components/Tooltip';
|
|
||||||
import Document from 'models/Document';
|
import Document from 'models/Document';
|
||||||
import User from 'models/User';
|
|
||||||
import UserProfile from 'scenes/UserProfile';
|
|
||||||
import ViewsStore from 'stores/ViewsStore';
|
import ViewsStore from 'stores/ViewsStore';
|
||||||
import DocumentPresenceStore from 'stores/DocumentPresenceStore';
|
import DocumentPresenceStore from 'stores/DocumentPresenceStore';
|
||||||
import { EditIcon } from 'outline-icons';
|
|
||||||
|
|
||||||
const MAX_DISPLAY = 6;
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
views: ViewsStore,
|
views: ViewsStore,
|
||||||
|
@ -25,66 +17,6 @@ type Props = {
|
||||||
currentUserId: string,
|
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 (
|
|
||||||
<React.Fragment>
|
|
||||||
<Tooltip
|
|
||||||
tooltip={
|
|
||||||
<Centered>
|
|
||||||
<strong>{user.name}</strong> {isCurrentUser && '(You)'}
|
|
||||||
<br />
|
|
||||||
{isPresent
|
|
||||||
? isEditing ? 'currently editing' : 'currently viewing'
|
|
||||||
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
|
|
||||||
</Centered>
|
|
||||||
}
|
|
||||||
placement="bottom"
|
|
||||||
>
|
|
||||||
<AvatarWrapper isPresent={isPresent}>
|
|
||||||
<Avatar
|
|
||||||
src={user.avatarUrl}
|
|
||||||
onClick={this.handleOpenProfile}
|
|
||||||
size={32}
|
|
||||||
icon={isEditing ? <EditIcon size={16} color="#FFF" /> : undefined}
|
|
||||||
/>
|
|
||||||
</AvatarWrapper>
|
|
||||||
</Tooltip>
|
|
||||||
<UserProfile
|
|
||||||
user={user}
|
|
||||||
isOpen={this.isOpen}
|
|
||||||
onRequestClose={this.handleCloseProfile}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class Collaborators extends React.Component<Props> {
|
class Collaborators extends React.Component<Props> {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
@ -93,33 +25,37 @@ class Collaborators extends React.Component<Props> {
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { document, presence, views, currentUserId } = this.props;
|
const { document, presence, views, currentUserId } = this.props;
|
||||||
const documentViews = views.inDocument(document.id);
|
|
||||||
let documentPresence = presence.get(document.id);
|
let documentPresence = presence.get(document.id);
|
||||||
documentPresence = documentPresence
|
documentPresence = documentPresence
|
||||||
? Array.from(documentPresence.values())
|
? Array.from(documentPresence.values())
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const documentViews = views.inDocument(document.id);
|
||||||
|
|
||||||
const presentIds = documentPresence.map(p => p.userId);
|
const presentIds = documentPresence.map(p => p.userId);
|
||||||
const editingIds = documentPresence
|
const editingIds = documentPresence
|
||||||
.filter(p => p.isEditing)
|
.filter(p => p.isEditing)
|
||||||
.map(p => p.userId);
|
.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
|
// ensure currently present via websocket are always ordered first
|
||||||
mostRecentViewers = sortBy(mostRecentViewers, view =>
|
const mostRecentViewers = sortBy(
|
||||||
presentIds.includes(view.user.id)
|
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;
|
const overflow = documentViews.length - mostRecentViewers.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Avatars>
|
<Facepile
|
||||||
{overflow > 0 && <More>+{overflow}</More>}
|
users={mostRecentViewers.map(v => v.user)}
|
||||||
{mostRecentViewers.map(({ lastViewedAt, user }) => {
|
overflow={overflow}
|
||||||
|
renderAvatar={user => {
|
||||||
const isPresent = presentIds.includes(user.id);
|
const isPresent = presentIds.includes(user.id);
|
||||||
const isEditing = editingIds.includes(user.id);
|
const isEditing = editingIds.includes(user.id);
|
||||||
|
const { lastViewedAt } = viewersKeyedByUserId[user.id];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AvatarWithPresence
|
<AvatarWithPresence
|
||||||
|
@ -131,45 +67,10 @@ class Collaborators extends React.Component<Props> {
|
||||||
isCurrentUser={currentUserId === user.id}
|
isCurrentUser={currentUserId === user.id}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
}}
|
||||||
</Avatars>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Centered = styled.div`
|
export default inject('views', 'presence')(Collaborators);
|
||||||
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));
|
|
||||||
|
|
|
@ -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<Props> {
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
users,
|
||||||
|
overflow,
|
||||||
|
size = 32,
|
||||||
|
renderAvatar = renderDefaultAvatar,
|
||||||
|
...rest
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatars {...rest}>
|
||||||
|
{overflow > 0 && (
|
||||||
|
<More size={size}>
|
||||||
|
<span>+{overflow}</span>
|
||||||
|
</More>
|
||||||
|
)}
|
||||||
|
{users.map(user => (
|
||||||
|
<AvatarWrapper key={user.id}>{renderAvatar(user)}</AvatarWrapper>
|
||||||
|
))}
|
||||||
|
</Avatars>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDefaultAvatar(user: User) {
|
||||||
|
return <Avatar user={user} src={user.avatarUrl} size={32} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
|
@ -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<Props> {
|
||||||
|
@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 (
|
||||||
|
<React.Fragment>
|
||||||
|
<ListItem
|
||||||
|
title={
|
||||||
|
<Title onClick={this.handleMembersModalOpen}>{group.name}</Title>
|
||||||
|
}
|
||||||
|
subtitle={
|
||||||
|
<React.Fragment>
|
||||||
|
{memberCount} member{memberCount === 1 ? '' : 's'}
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
<Flex align="center">
|
||||||
|
{showFacepile && (
|
||||||
|
<Facepile
|
||||||
|
onClick={this.handleMembersModalOpen}
|
||||||
|
users={users}
|
||||||
|
overflow={overflow}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderActions({
|
||||||
|
openMembersModal: this.handleMembersModalOpen,
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="Group members"
|
||||||
|
onRequestClose={this.handleMembersModalClose}
|
||||||
|
isOpen={this.membersModalOpen}
|
||||||
|
>
|
||||||
|
<GroupMembers group={group} onSubmit={this.handleMembersModalClose} />
|
||||||
|
</Modal>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const Title = styled.span`
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default inject('groupMemberships')(GroupListItem);
|
|
@ -42,6 +42,7 @@ const Image = styled(Flex)`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
align-self: flex-start;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Heading = styled.p`
|
const Heading = styled.p`
|
||||||
|
|
|
@ -41,6 +41,12 @@ const GlobalStyles = createGlobalStyle`
|
||||||
margin-left: 24px;
|
margin-left: 24px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ReactModalPortal + .ReactModalPortal + .ReactModalPortal + .ReactModalPortal {
|
||||||
|
.ReactModal__Overlay {
|
||||||
|
margin-left: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
`};
|
`};
|
||||||
|
|
||||||
.ReactModal__Body--open {
|
.ReactModal__Body--open {
|
||||||
|
|
|
@ -27,8 +27,12 @@ class PaginatedList extends React.Component<Props> {
|
||||||
@observable offset: number = 0;
|
@observable offset: number = 0;
|
||||||
@observable allowLoadMore: boolean = true;
|
@observable allowLoadMore: boolean = true;
|
||||||
|
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.isInitiallyLoaded = this.props.items.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.isInitiallyLoaded = !!this.props.items.length;
|
|
||||||
this.fetchResults();
|
this.fetchResults();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
PadlockIcon,
|
PadlockIcon,
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
GroupIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
TeamIcon,
|
TeamIcon,
|
||||||
BulletedListIcon,
|
BulletedListIcon,
|
||||||
|
@ -96,6 +97,12 @@ class SettingsSidebar extends React.Component<Props> {
|
||||||
exact={false}
|
exact={false}
|
||||||
label="People"
|
label="People"
|
||||||
/>
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
to="/settings/groups"
|
||||||
|
icon={<GroupIcon />}
|
||||||
|
exact={false}
|
||||||
|
label="Groups"
|
||||||
|
/>
|
||||||
<SidebarLink
|
<SidebarLink
|
||||||
to="/settings/shares"
|
to="/settings/shares"
|
||||||
icon={<LinkIcon />}
|
icon={<LinkIcon />}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { find } from 'lodash';
|
||||||
import io from 'socket.io-client';
|
import io from 'socket.io-client';
|
||||||
import DocumentsStore from 'stores/DocumentsStore';
|
import DocumentsStore from 'stores/DocumentsStore';
|
||||||
import CollectionsStore from 'stores/CollectionsStore';
|
import CollectionsStore from 'stores/CollectionsStore';
|
||||||
|
import GroupsStore from 'stores/GroupsStore';
|
||||||
import MembershipsStore from 'stores/MembershipsStore';
|
import MembershipsStore from 'stores/MembershipsStore';
|
||||||
import DocumentPresenceStore from 'stores/DocumentPresenceStore';
|
import DocumentPresenceStore from 'stores/DocumentPresenceStore';
|
||||||
import PoliciesStore from 'stores/PoliciesStore';
|
import PoliciesStore from 'stores/PoliciesStore';
|
||||||
|
@ -19,6 +20,7 @@ type Props = {
|
||||||
children: React.Node,
|
children: React.Node,
|
||||||
documents: DocumentsStore,
|
documents: DocumentsStore,
|
||||||
collections: CollectionsStore,
|
collections: CollectionsStore,
|
||||||
|
groups: GroupsStore,
|
||||||
memberships: MembershipsStore,
|
memberships: MembershipsStore,
|
||||||
presence: DocumentPresenceStore,
|
presence: DocumentPresenceStore,
|
||||||
policies: PoliciesStore,
|
policies: PoliciesStore,
|
||||||
|
@ -44,6 +46,7 @@ class SocketProvider extends React.Component<Props> {
|
||||||
ui,
|
ui,
|
||||||
documents,
|
documents,
|
||||||
collections,
|
collections,
|
||||||
|
groups,
|
||||||
memberships,
|
memberships,
|
||||||
policies,
|
policies,
|
||||||
presence,
|
presence,
|
||||||
|
@ -173,6 +176,28 @@ class SocketProvider extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 => {
|
this.socket.on('documents.star', event => {
|
||||||
|
@ -270,6 +295,7 @@ export default inject(
|
||||||
'ui',
|
'ui',
|
||||||
'documents',
|
'documents',
|
||||||
'collections',
|
'collections',
|
||||||
|
'groups',
|
||||||
'memberships',
|
'memberships',
|
||||||
'presence',
|
'presence',
|
||||||
'policies',
|
'policies',
|
||||||
|
|
|
@ -105,7 +105,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||||
</VisuallyHidden>
|
</VisuallyHidden>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="Collection members"
|
title="Collection permissions"
|
||||||
onRequestClose={this.handleMembersModalClose}
|
onRequestClose={this.handleMembersModalClose}
|
||||||
isOpen={this.membersModalOpen}
|
isOpen={this.membersModalOpen}
|
||||||
>
|
>
|
||||||
|
@ -134,7 +134,7 @@ class CollectionMenu extends React.Component<Props> {
|
||||||
)}
|
)}
|
||||||
{can.update && (
|
{can.update && (
|
||||||
<DropdownMenuItem onClick={this.onPermissions}>
|
<DropdownMenuItem onClick={this.onPermissions}>
|
||||||
Members…
|
Permissions…
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{can.export && (
|
{can.export && (
|
||||||
|
|
|
@ -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<Props> {
|
||||||
|
@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 (
|
||||||
|
<React.Fragment>
|
||||||
|
<Modal
|
||||||
|
title="Edit group"
|
||||||
|
onRequestClose={this.handleEditModalClose}
|
||||||
|
isOpen={this.editModalOpen}
|
||||||
|
>
|
||||||
|
<GroupEdit
|
||||||
|
group={this.props.group}
|
||||||
|
onSubmit={this.handleEditModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Delete group"
|
||||||
|
onRequestClose={this.handleDeleteModalClose}
|
||||||
|
isOpen={this.deleteModalOpen}
|
||||||
|
>
|
||||||
|
<GroupDelete
|
||||||
|
group={this.props.group}
|
||||||
|
onSubmit={this.handleDeleteModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<DropdownMenu onOpen={onOpen} onClose={onClose}>
|
||||||
|
{group && (
|
||||||
|
<React.Fragment>
|
||||||
|
<DropdownMenuItem onClick={this.props.onMembers}>
|
||||||
|
Members…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{(can.update || can.delete) && <hr />}
|
||||||
|
|
||||||
|
{can.update && (
|
||||||
|
<DropdownMenuItem onClick={this.onEdit}>Edit…</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{can.delete && (
|
||||||
|
<DropdownMenuItem onClick={this.onDelete}>
|
||||||
|
Delete…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</DropdownMenu>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('policies')(withRouter(GroupMenu));
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,10 @@
|
||||||
|
// @flow
|
||||||
|
import BaseModel from './BaseModel';
|
||||||
|
|
||||||
|
class GroupMembership extends BaseModel {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
groupId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GroupMembership;
|
|
@ -16,6 +16,7 @@ import Details from 'scenes/Settings/Details';
|
||||||
import Notifications from 'scenes/Settings/Notifications';
|
import Notifications from 'scenes/Settings/Notifications';
|
||||||
import Security from 'scenes/Settings/Security';
|
import Security from 'scenes/Settings/Security';
|
||||||
import People from 'scenes/Settings/People';
|
import People from 'scenes/Settings/People';
|
||||||
|
import Groups from 'scenes/Settings/Groups';
|
||||||
import Slack from 'scenes/Settings/Slack';
|
import Slack from 'scenes/Settings/Slack';
|
||||||
import Zapier from 'scenes/Settings/Zapier';
|
import Zapier from 'scenes/Settings/Zapier';
|
||||||
import Shares from 'scenes/Settings/Shares';
|
import Shares from 'scenes/Settings/Shares';
|
||||||
|
@ -56,6 +57,7 @@ export default function Routes() {
|
||||||
<Route exact path="/settings/security" component={Security} />
|
<Route exact path="/settings/security" component={Security} />
|
||||||
<Route exact path="/settings/people" component={People} />
|
<Route exact path="/settings/people" component={People} />
|
||||||
<Route exact path="/settings/people/:filter" component={People} />
|
<Route exact path="/settings/people/:filter" component={People} />
|
||||||
|
<Route exact path="/settings/groups" component={Groups} />
|
||||||
<Route exact path="/settings/shares" component={Shares} />
|
<Route exact path="/settings/shares" component={Shares} />
|
||||||
<Route exact path="/settings/tokens" component={Tokens} />
|
<Route exact path="/settings/tokens" component={Tokens} />
|
||||||
<Route exact path="/settings/events" component={Events} />
|
<Route exact path="/settings/events" component={Events} />
|
||||||
|
|
|
@ -186,7 +186,7 @@ class CollectionScene extends React.Component<Props> {
|
||||||
)}
|
)}
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
<Modal
|
<Modal
|
||||||
title="Collection members"
|
title="Collection permissions"
|
||||||
onRequestClose={this.handlePermissionsModalClose}
|
onRequestClose={this.handlePermissionsModalClose}
|
||||||
isOpen={this.permissionsModalOpen}
|
isOpen={this.permissionsModalOpen}
|
||||||
>
|
>
|
||||||
|
|
|
@ -26,7 +26,7 @@ class CollectionEdit extends React.Component<Props> {
|
||||||
@observable isSaving: boolean;
|
@observable isSaving: boolean;
|
||||||
@observable private: boolean = false;
|
@observable private: boolean = false;
|
||||||
|
|
||||||
componentWillMount() {
|
componentDidMount() {
|
||||||
this.name = this.props.collection.name;
|
this.name = this.props.collection.name;
|
||||||
this.description = this.props.collection.description;
|
this.description = this.props.collection.description;
|
||||||
this.color = this.props.collection.color;
|
this.color = this.props.collection.color;
|
||||||
|
|
|
@ -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<Props> {
|
||||||
|
@observable newGroupModalOpen: boolean = false;
|
||||||
|
@observable query: string = '';
|
||||||
|
|
||||||
|
handleNewGroupModalOpen = () => {
|
||||||
|
this.newGroupModalOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleNewGroupModalClose = () => {
|
||||||
|
this.newGroupModalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleFilter = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Flex column>
|
||||||
|
<HelpText>
|
||||||
|
Can’t find the group you’re looking for?{' '}
|
||||||
|
<a role="button" onClick={this.handleNewGroupModalOpen}>
|
||||||
|
Create a group
|
||||||
|
</a>.
|
||||||
|
</HelpText>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by group name…"
|
||||||
|
value={this.query}
|
||||||
|
onChange={this.handleFilter}
|
||||||
|
label="Search groups"
|
||||||
|
labelHidden
|
||||||
|
flex
|
||||||
|
/>
|
||||||
|
<PaginatedList
|
||||||
|
empty={
|
||||||
|
this.query ? (
|
||||||
|
<Empty>No groups matching your search</Empty>
|
||||||
|
) : (
|
||||||
|
<Empty>No groups left to add</Empty>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items={groups.notInCollection(collection.id, this.query)}
|
||||||
|
fetch={this.query ? undefined : groups.fetchPage}
|
||||||
|
renderItem={item => (
|
||||||
|
<GroupListItem
|
||||||
|
key={item.id}
|
||||||
|
group={item}
|
||||||
|
showFacepile
|
||||||
|
renderActions={() => (
|
||||||
|
<ButtonWrap>
|
||||||
|
<Button onClick={() => this.handleAddGroup(item)} neutral>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</ButtonWrap>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="Create a group"
|
||||||
|
onRequestClose={this.handleNewGroupModalClose}
|
||||||
|
isOpen={this.newGroupModalOpen}
|
||||||
|
>
|
||||||
|
<GroupNew onSubmit={this.handleNewGroupModalClose} />
|
||||||
|
</Modal>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ButtonWrap = styled.div`
|
||||||
|
margin-left: 6px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default inject('auth', 'groups', 'collectionGroupMemberships', 'ui')(
|
||||||
|
AddGroupsToCollection
|
||||||
|
);
|
|
@ -1,21 +1,27 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { observable } from 'mobx';
|
import { observable } from 'mobx';
|
||||||
|
import styled from 'styled-components';
|
||||||
import { inject, observer } from 'mobx-react';
|
import { inject, observer } from 'mobx-react';
|
||||||
import { PlusIcon } from 'outline-icons';
|
import { PlusIcon } from 'outline-icons';
|
||||||
import Flex from 'shared/components/Flex';
|
import Flex from 'shared/components/Flex';
|
||||||
import HelpText from 'components/HelpText';
|
import HelpText from 'components/HelpText';
|
||||||
import Subheading from 'components/Subheading';
|
import Subheading from 'components/Subheading';
|
||||||
import Button from 'components/Button';
|
import Button from 'components/Button';
|
||||||
|
import Empty from 'components/Empty';
|
||||||
import PaginatedList from 'components/PaginatedList';
|
import PaginatedList from 'components/PaginatedList';
|
||||||
import Modal from 'components/Modal';
|
import Modal from 'components/Modal';
|
||||||
|
import CollectionGroupMemberListItem from './components/CollectionGroupMemberListItem';
|
||||||
import Collection from 'models/Collection';
|
import Collection from 'models/Collection';
|
||||||
import UiStore from 'stores/UiStore';
|
import UiStore from 'stores/UiStore';
|
||||||
import AuthStore from 'stores/AuthStore';
|
import AuthStore from 'stores/AuthStore';
|
||||||
import MembershipsStore from 'stores/MembershipsStore';
|
import MembershipsStore from 'stores/MembershipsStore';
|
||||||
|
import CollectionGroupMembershipsStore from 'stores/CollectionGroupMembershipsStore';
|
||||||
import UsersStore from 'stores/UsersStore';
|
import UsersStore from 'stores/UsersStore';
|
||||||
import MemberListItem from './components/MemberListItem';
|
import MemberListItem from './components/MemberListItem';
|
||||||
import AddPeopleToCollection from './AddPeopleToCollection';
|
import AddPeopleToCollection from './AddPeopleToCollection';
|
||||||
|
import AddGroupsToCollection from './AddGroupsToCollection';
|
||||||
|
import GroupsStore from 'stores/GroupsStore';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
ui: UiStore,
|
ui: UiStore,
|
||||||
|
@ -23,19 +29,30 @@ type Props = {
|
||||||
collection: Collection,
|
collection: Collection,
|
||||||
users: UsersStore,
|
users: UsersStore,
|
||||||
memberships: MembershipsStore,
|
memberships: MembershipsStore,
|
||||||
|
collectionGroupMemberships: CollectionGroupMembershipsStore,
|
||||||
|
groups: GroupsStore,
|
||||||
onEdit: () => void,
|
onEdit: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
@observer
|
@observer
|
||||||
class CollectionMembers extends React.Component<Props> {
|
class CollectionMembers extends React.Component<Props> {
|
||||||
@observable addModalOpen: boolean = false;
|
@observable addGroupModalOpen: boolean = false;
|
||||||
|
@observable addMemberModalOpen: boolean = false;
|
||||||
|
|
||||||
handleAddModalOpen = () => {
|
handleAddGroupModalOpen = () => {
|
||||||
this.addModalOpen = true;
|
this.addGroupModalOpen = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
handleAddModalClose = () => {
|
handleAddGroupModalClose = () => {
|
||||||
this.addModalOpen = false;
|
this.addGroupModalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAddMemberModalOpen = () => {
|
||||||
|
this.addMemberModalOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAddMemberModalClose = () => {
|
||||||
|
this.addMemberModalOpen = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
handleRemoveUser = user => {
|
handleRemoveUser = user => {
|
||||||
|
@ -63,8 +80,40 @@ class CollectionMembers extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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() {
|
render() {
|
||||||
const { collection, users, memberships, auth } = this.props;
|
const {
|
||||||
|
collection,
|
||||||
|
users,
|
||||||
|
groups,
|
||||||
|
memberships,
|
||||||
|
collectionGroupMemberships,
|
||||||
|
auth,
|
||||||
|
} = this.props;
|
||||||
const { user } = auth;
|
const { user } = auth;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
|
@ -78,9 +127,10 @@ class CollectionMembers extends React.Component<Props> {
|
||||||
{collection.private ? (
|
{collection.private ? (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<HelpText>
|
<HelpText>
|
||||||
Choose which team members have access to view and edit documents
|
Choose which groups and team members have access to view and edit
|
||||||
in the private <strong>{collection.name}</strong> collection. You
|
documents in the private <strong>{collection.name}</strong>{' '}
|
||||||
can make this collection visible to the entire team by{' '}
|
collection. You can make this collection visible to the entire
|
||||||
|
team by{' '}
|
||||||
<a role="button" onClick={this.props.onEdit}>
|
<a role="button" onClick={this.props.onEdit}>
|
||||||
changing its visibility
|
changing its visibility
|
||||||
</a>.
|
</a>.
|
||||||
|
@ -88,11 +138,11 @@ class CollectionMembers extends React.Component<Props> {
|
||||||
<span>
|
<span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={this.handleAddModalOpen}
|
onClick={this.handleAddGroupModalOpen}
|
||||||
icon={<PlusIcon />}
|
icon={<PlusIcon />}
|
||||||
neutral
|
neutral
|
||||||
>
|
>
|
||||||
Add people
|
Add groups
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
@ -107,7 +157,59 @@ class CollectionMembers extends React.Component<Props> {
|
||||||
</HelpText>
|
</HelpText>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{collection.private && (
|
||||||
|
<GroupsWrap>
|
||||||
|
<Subheading>Groups</Subheading>
|
||||||
|
<PaginatedList
|
||||||
|
key={key}
|
||||||
|
items={groups.inCollection(collection.id)}
|
||||||
|
fetch={collectionGroupMemberships.fetchPage}
|
||||||
|
options={collection.private ? { id: collection.id } : undefined}
|
||||||
|
empty={<Empty>This collection has no groups.</Empty>}
|
||||||
|
renderItem={group => (
|
||||||
|
<CollectionGroupMemberListItem
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
collectionGroupMembership={collectionGroupMemberships.get(
|
||||||
|
`${group.id}-${collection.id}`
|
||||||
|
)}
|
||||||
|
onRemove={() => this.handleRemoveGroup(group)}
|
||||||
|
onUpdate={permission =>
|
||||||
|
this.handleUpdateGroup(group, permission)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title={`Add groups to ${collection.name}`}
|
||||||
|
onRequestClose={this.handleAddGroupModalClose}
|
||||||
|
isOpen={this.addGroupModalOpen}
|
||||||
|
>
|
||||||
|
<AddGroupsToCollection
|
||||||
|
collection={collection}
|
||||||
|
onSubmit={this.handleAddGroupModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</GroupsWrap>
|
||||||
|
)}
|
||||||
|
{collection.private ? (
|
||||||
|
<React.Fragment>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleAddMemberModalOpen}
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
neutral
|
||||||
|
>
|
||||||
|
Add individual members
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Subheading>Individual Members</Subheading>
|
||||||
|
</React.Fragment>
|
||||||
|
) : (
|
||||||
<Subheading>Members</Subheading>
|
<Subheading>Members</Subheading>
|
||||||
|
)}
|
||||||
<PaginatedList
|
<PaginatedList
|
||||||
key={key}
|
key={key}
|
||||||
items={
|
items={
|
||||||
|
@ -130,12 +232,12 @@ class CollectionMembers extends React.Component<Props> {
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title={`Add people to ${collection.name}`}
|
title={`Add people to ${collection.name}`}
|
||||||
onRequestClose={this.handleAddModalClose}
|
onRequestClose={this.handleAddMemberModalClose}
|
||||||
isOpen={this.addModalOpen}
|
isOpen={this.addMemberModalOpen}
|
||||||
>
|
>
|
||||||
<AddPeopleToCollection
|
<AddPeopleToCollection
|
||||||
collection={collection}
|
collection={collection}
|
||||||
onSubmit={this.handleAddModalClose}
|
onSubmit={this.handleAddMemberModalClose}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
@ -143,4 +245,15 @@ class CollectionMembers extends React.Component<Props> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
|
@ -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 (
|
||||||
|
<GroupListItem
|
||||||
|
group={group}
|
||||||
|
onRemove={onRemove}
|
||||||
|
onUpdate={onUpdate}
|
||||||
|
renderActions={({ openMembersModal }) => (
|
||||||
|
<React.Fragment>
|
||||||
|
<Select
|
||||||
|
label="Permissions"
|
||||||
|
options={PERMISSIONS}
|
||||||
|
value={
|
||||||
|
collectionGroupMembership
|
||||||
|
? collectionGroupMembership.permission
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={ev => onUpdate(ev.target.value)}
|
||||||
|
labelHidden
|
||||||
|
/>
|
||||||
|
<ButtonWrap>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuItem onClick={openMembersModal}>
|
||||||
|
Members…
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<hr />
|
||||||
|
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</ButtonWrap>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Select = styled(InputSelect)`
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ButtonWrap = styled.div`
|
||||||
|
margin-left: 6px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default MemberListItem;
|
|
@ -49,7 +49,7 @@ const MemberListItem = ({
|
||||||
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
}
|
}
|
||||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
image={<Avatar src={user.avatarUrl} size={40} />}
|
||||||
actions={
|
actions={
|
||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
{canEdit &&
|
{canEdit &&
|
||||||
|
|
|
@ -18,7 +18,7 @@ const UserListItem = ({ user, onAdd, canEdit }: Props) => {
|
||||||
return (
|
return (
|
||||||
<ListItem
|
<ListItem
|
||||||
title={user.name}
|
title={user.name}
|
||||||
image={<Avatar src={user.avatarUrl} size={32} />}
|
image={<Avatar src={user.avatarUrl} size={40} />}
|
||||||
subtitle={
|
subtitle={
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{user.lastActiveAt ? (
|
{user.lastActiveAt ? (
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
// @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 { groupSettings } from 'shared/utils/routeHelpers';
|
||||||
|
import Button from 'components/Button';
|
||||||
|
import Flex from 'shared/components/Flex';
|
||||||
|
import HelpText from 'components/HelpText';
|
||||||
|
import Group from 'models/Group';
|
||||||
|
import UiStore from 'stores/UiStore';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
history: RouterHistory,
|
||||||
|
group: Group,
|
||||||
|
ui: UiStore,
|
||||||
|
onSubmit: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class GroupDelete extends React.Component<Props> {
|
||||||
|
@observable isDeleting: boolean;
|
||||||
|
|
||||||
|
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.isDeleting = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.props.group.delete();
|
||||||
|
this.props.history.push(groupSettings());
|
||||||
|
this.props.onSubmit();
|
||||||
|
} catch (err) {
|
||||||
|
this.props.ui.showToast(err.message);
|
||||||
|
} finally {
|
||||||
|
this.isDeleting = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { group } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex column>
|
||||||
|
<form onSubmit={this.handleSubmit}>
|
||||||
|
<HelpText>
|
||||||
|
Are you sure about that? Deleting the <strong>{group.name}</strong>{' '}
|
||||||
|
group will cause its members to lose access to collections and
|
||||||
|
documents that it is associated with.
|
||||||
|
</HelpText>
|
||||||
|
<Button type="submit" danger>
|
||||||
|
{this.isDeleting ? 'Deleting…' : 'I’m sure – Delete'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('ui')(withRouter(GroupDelete));
|
|
@ -0,0 +1,71 @@
|
||||||
|
// @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 Flex from 'shared/components/Flex';
|
||||||
|
|
||||||
|
import Group from 'models/Group';
|
||||||
|
import UiStore from 'stores/UiStore';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
history: RouterHistory,
|
||||||
|
ui: UiStore,
|
||||||
|
group: Group,
|
||||||
|
onSubmit: () => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
@observer
|
||||||
|
class GroupEdit extends React.Component<Props> {
|
||||||
|
@observable name: string = this.props.group.name;
|
||||||
|
@observable isSaving: boolean;
|
||||||
|
|
||||||
|
handleSubmit = async (ev: SyntheticEvent<>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.isSaving = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.props.group.save({ name: this.name });
|
||||||
|
this.props.onSubmit();
|
||||||
|
} catch (err) {
|
||||||
|
this.props.ui.showToast(err.message);
|
||||||
|
} finally {
|
||||||
|
this.isSaving = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleNameChange = (ev: SyntheticInputEvent<*>) => {
|
||||||
|
this.name = ev.target.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<form onSubmit={this.handleSubmit}>
|
||||||
|
<HelpText>
|
||||||
|
You can edit the name of this group at any time, however doing so too
|
||||||
|
often might confuse your team mates.
|
||||||
|
</HelpText>
|
||||||
|
<Flex>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Name"
|
||||||
|
onChange={this.handleNameChange}
|
||||||
|
value={this.name}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
flex
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||||
|
{this.isSaving ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('ui')(withRouter(GroupEdit));
|
|
@ -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<Props> {
|
||||||
|
@observable inviteModalOpen: boolean = false;
|
||||||
|
@observable query: string = '';
|
||||||
|
|
||||||
|
handleInviteModalOpen = () => {
|
||||||
|
this.inviteModalOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleInviteModalClose = () => {
|
||||||
|
this.inviteModalOpen = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleFilter = (ev: SyntheticInputEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<Flex column>
|
||||||
|
<HelpText>
|
||||||
|
Add team members below to give them access to the group. Need to add
|
||||||
|
someone who’s not yet on the team yet?{' '}
|
||||||
|
<a role="button" onClick={this.handleInviteModalOpen}>
|
||||||
|
Invite them to {team.name}
|
||||||
|
</a>.
|
||||||
|
</HelpText>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search by name…"
|
||||||
|
value={this.query}
|
||||||
|
onChange={this.handleFilter}
|
||||||
|
label="Search people"
|
||||||
|
labelHidden
|
||||||
|
autoFocus
|
||||||
|
flex
|
||||||
|
/>
|
||||||
|
<PaginatedList
|
||||||
|
empty={
|
||||||
|
this.query ? (
|
||||||
|
<Empty>No people matching your search</Empty>
|
||||||
|
) : (
|
||||||
|
<Empty>No people left to add</Empty>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items={users.notInGroup(group.id, this.query)}
|
||||||
|
fetch={this.query ? undefined : users.fetchPage}
|
||||||
|
renderItem={item => (
|
||||||
|
<GroupMemberListItem
|
||||||
|
key={item.id}
|
||||||
|
user={item}
|
||||||
|
onAdd={() => this.handleAddUser(item)}
|
||||||
|
canEdit
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
title="Invite people"
|
||||||
|
onRequestClose={this.handleInviteModalClose}
|
||||||
|
isOpen={this.inviteModalOpen}
|
||||||
|
>
|
||||||
|
<Invite onSubmit={this.handleInviteModalClose} />
|
||||||
|
</Modal>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('auth', 'users', 'groupMemberships', 'ui')(
|
||||||
|
AddPeopleToGroup
|
||||||
|
);
|
|
@ -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<Props> {
|
||||||
|
@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 (
|
||||||
|
<Flex column>
|
||||||
|
{can.update ? (
|
||||||
|
<React.Fragment>
|
||||||
|
<HelpText>
|
||||||
|
Add and remove team members in the <strong>{group.name}</strong>{' '}
|
||||||
|
group. Adding people to the group will give them access to any
|
||||||
|
collections this group has been given access to.
|
||||||
|
</HelpText>
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleAddModalOpen}
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
neutral
|
||||||
|
>
|
||||||
|
Add people…
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</React.Fragment>
|
||||||
|
) : (
|
||||||
|
<HelpText>
|
||||||
|
Listing team members in the <strong>{group.name}</strong> group.
|
||||||
|
</HelpText>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Subheading>Members</Subheading>
|
||||||
|
<PaginatedList
|
||||||
|
items={users.inGroup(group.id)}
|
||||||
|
fetch={groupMemberships.fetchPage}
|
||||||
|
options={{ id: group.id }}
|
||||||
|
empty={<Empty>This group has no members.</Empty>}
|
||||||
|
renderItem={item => (
|
||||||
|
<GroupMemberListItem
|
||||||
|
key={item.id}
|
||||||
|
user={item}
|
||||||
|
membership={groupMemberships.get(`${item.id}-${group.id}`)}
|
||||||
|
onRemove={
|
||||||
|
can.update ? () => this.handleRemoveUser(item) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{can.update && (
|
||||||
|
<Modal
|
||||||
|
title={`Add people to ${group.name}`}
|
||||||
|
onRequestClose={this.handleAddModalClose}
|
||||||
|
isOpen={this.addModalOpen}
|
||||||
|
>
|
||||||
|
<AddPeopleToGroup
|
||||||
|
group={group}
|
||||||
|
onSubmit={this.handleAddModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('auth', 'users', 'policies', 'groupMemberships', 'ui')(
|
||||||
|
GroupMembers
|
||||||
|
);
|
|
@ -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<void>,
|
||||||
|
onRemove?: () => Promise<void>,
|
||||||
|
};
|
||||||
|
|
||||||
|
const GroupMemberListItem = ({
|
||||||
|
user,
|
||||||
|
groupMembership,
|
||||||
|
onRemove,
|
||||||
|
onAdd,
|
||||||
|
}: Props) => {
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
title={user.name}
|
||||||
|
subtitle={
|
||||||
|
<React.Fragment>
|
||||||
|
{user.lastActiveAt ? (
|
||||||
|
<React.Fragment>
|
||||||
|
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||||
|
</React.Fragment>
|
||||||
|
) : (
|
||||||
|
'Never signed in'
|
||||||
|
)}
|
||||||
|
{!user.lastActiveAt && <Badge>Invited</Badge>}
|
||||||
|
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
image={<Avatar src={user.avatarUrl} size={40} />}
|
||||||
|
actions={
|
||||||
|
<Flex align="center">
|
||||||
|
{onRemove && (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuItem onClick={onRemove}>Remove</DropdownMenuItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
{onAdd && (
|
||||||
|
<Button onClick={onAdd} neutral>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupMemberListItem;
|
|
@ -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 (
|
||||||
|
<ListItem
|
||||||
|
title={user.name}
|
||||||
|
image={<Avatar src={user.avatarUrl} size={32} />}
|
||||||
|
subtitle={
|
||||||
|
<React.Fragment>
|
||||||
|
{user.lastActiveAt ? (
|
||||||
|
<React.Fragment>
|
||||||
|
Active <Time dateTime={user.lastActiveAt} /> ago
|
||||||
|
</React.Fragment>
|
||||||
|
) : (
|
||||||
|
'Never signed in'
|
||||||
|
)}
|
||||||
|
{!user.lastActiveAt && <Badge>Invited</Badge>}
|
||||||
|
{user.isAdmin && <Badge admin={user.isAdmin}>Admin</Badge>}
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
canEdit ? (
|
||||||
|
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserListItem;
|
|
@ -0,0 +1,3 @@
|
||||||
|
// @flow
|
||||||
|
import GroupMembers from './GroupMembers';
|
||||||
|
export default GroupMembers;
|
|
@ -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<Props> {
|
||||||
|
@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 (
|
||||||
|
<React.Fragment>
|
||||||
|
<form onSubmit={this.handleSubmit}>
|
||||||
|
<HelpText>
|
||||||
|
Groups are for organizing your team. They work best when centered
|
||||||
|
around a function or a responsibility — Support or Engineering for
|
||||||
|
example.
|
||||||
|
</HelpText>
|
||||||
|
<Flex>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
label="Name"
|
||||||
|
onChange={this.handleNameChange}
|
||||||
|
value={this.name}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
flex
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
<HelpText>You’ll be able to add people to the group next.</HelpText>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={this.isSaving || !this.name}>
|
||||||
|
{this.isSaving ? 'Creating…' : 'Continue'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<Modal
|
||||||
|
title="Group members"
|
||||||
|
onRequestClose={this.props.onSubmit}
|
||||||
|
isOpen={!!this.group}
|
||||||
|
>
|
||||||
|
<GroupMembers group={this.group} />
|
||||||
|
</Modal>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('groups', 'ui')(withRouter(GroupNew));
|
|
@ -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<Props> {
|
||||||
|
@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 (
|
||||||
|
<CenteredContent>
|
||||||
|
<PageTitle title="People" />
|
||||||
|
<h1>Groups</h1>
|
||||||
|
<HelpText>
|
||||||
|
Groups can be used to organize and manage the people on your team.
|
||||||
|
</HelpText>
|
||||||
|
|
||||||
|
{can.group && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleNewGroupModalOpen}
|
||||||
|
icon={<PlusIcon />}
|
||||||
|
neutral
|
||||||
|
>
|
||||||
|
New group…
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Tabs>
|
||||||
|
<Tab to="/settings/groups" exact>
|
||||||
|
All Groups
|
||||||
|
</Tab>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<List>
|
||||||
|
{groups.orderedData.map(group => (
|
||||||
|
<GroupListItem
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
renderActions={({ openMembersModal }) => (
|
||||||
|
<GroupMenu group={group} onMembers={openMembersModal} />
|
||||||
|
)}
|
||||||
|
showFacepile
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
|
||||||
|
{showEmpty && <Empty>No groups to see here.</Empty>}
|
||||||
|
{showLoading && <ListPlaceholder count={5} />}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Create a group"
|
||||||
|
onRequestClose={this.handleNewGroupModalClose}
|
||||||
|
isOpen={this.newGroupModalOpen}
|
||||||
|
>
|
||||||
|
<GroupNew onSubmit={this.handleNewGroupModalClose} />
|
||||||
|
</Modal>
|
||||||
|
</CenteredContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default inject('auth', 'groups', 'policies')(Groups);
|
|
@ -6,6 +6,7 @@ import SharesStore from 'stores/SharesStore';
|
||||||
import AuthStore from 'stores/AuthStore';
|
import AuthStore from 'stores/AuthStore';
|
||||||
|
|
||||||
import ShareListItem from './components/ShareListItem';
|
import ShareListItem from './components/ShareListItem';
|
||||||
|
import Empty from 'components/Empty';
|
||||||
import List from 'components/List';
|
import List from 'components/List';
|
||||||
import CenteredContent from 'components/CenteredContent';
|
import CenteredContent from 'components/CenteredContent';
|
||||||
import Subheading from 'components/Subheading';
|
import Subheading from 'components/Subheading';
|
||||||
|
@ -48,15 +49,15 @@ class Shares extends React.Component<Props> {
|
||||||
sharing in <Link to="/settings/security">security settings</Link>.
|
sharing in <Link to="/settings/security">security settings</Link>.
|
||||||
</HelpText>
|
</HelpText>
|
||||||
)}
|
)}
|
||||||
{hasSharedDocuments && (
|
|
||||||
<React.Fragment>
|
|
||||||
<Subheading>Shared Documents</Subheading>
|
<Subheading>Shared Documents</Subheading>
|
||||||
|
{hasSharedDocuments ? (
|
||||||
<List>
|
<List>
|
||||||
{shares.orderedData.map(share => (
|
{shares.orderedData.map(share => (
|
||||||
<ShareListItem key={share.id} share={share} />
|
<ShareListItem key={share.id} share={share} />
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
</React.Fragment>
|
) : (
|
||||||
|
<Empty>No share links, yet.</Empty>
|
||||||
)}
|
)}
|
||||||
</CenteredContent>
|
</CenteredContent>
|
||||||
);
|
);
|
||||||
|
|
|
@ -78,19 +78,39 @@ const description = event => {
|
||||||
);
|
);
|
||||||
case 'users.delete':
|
case 'users.delete':
|
||||||
return 'Deleted their account';
|
return 'Deleted their account';
|
||||||
case 'collections.add_user':
|
case 'groups.create':
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
Added {event.data.name} to a private{' '}
|
Created the group <strong>{event.data.name}</strong>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
case 'groups.update':
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
Update the group <strong>{event.data.name}</strong>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
case 'groups.delete':
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
Deleted the group <strong>{event.data.name}</strong>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
case 'collections.add_user':
|
||||||
|
case 'collections.add_group':
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
Granted <strong>{event.data.name}</strong> access to a{' '}
|
||||||
<Link to={`/collections/${event.collectionId || ''}`}>
|
<Link to={`/collections/${event.collectionId || ''}`}>
|
||||||
collection
|
collection
|
||||||
</Link>
|
</Link>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
case 'collections.remove_user':
|
case 'collections.remove_user':
|
||||||
|
case 'collections.remove_group':
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
Remove {event.data.name} from a private{' '}
|
Revoked <strong>{event.data.name}</strong> access to a{' '}
|
||||||
<Link to={`/collections/${event.collectionId || ''}`}>
|
<Link to={`/collections/${event.collectionId || ''}`}>
|
||||||
collection
|
collection
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -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<GroupMembership> {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
|
@ -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<Group> {
|
||||||
|
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())
|
||||||
|
);
|
||||||
|
}
|
|
@ -4,6 +4,8 @@ import AuthStore from './AuthStore';
|
||||||
import CollectionsStore from './CollectionsStore';
|
import CollectionsStore from './CollectionsStore';
|
||||||
import DocumentsStore from './DocumentsStore';
|
import DocumentsStore from './DocumentsStore';
|
||||||
import EventsStore from './EventsStore';
|
import EventsStore from './EventsStore';
|
||||||
|
import GroupsStore from './GroupsStore';
|
||||||
|
import GroupMembershipsStore from './GroupMembershipsStore';
|
||||||
import IntegrationsStore from './IntegrationsStore';
|
import IntegrationsStore from './IntegrationsStore';
|
||||||
import MembershipsStore from './MembershipsStore';
|
import MembershipsStore from './MembershipsStore';
|
||||||
import NotificationSettingsStore from './NotificationSettingsStore';
|
import NotificationSettingsStore from './NotificationSettingsStore';
|
||||||
|
@ -14,13 +16,17 @@ import SharesStore from './SharesStore';
|
||||||
import UiStore from './UiStore';
|
import UiStore from './UiStore';
|
||||||
import UsersStore from './UsersStore';
|
import UsersStore from './UsersStore';
|
||||||
import ViewsStore from './ViewsStore';
|
import ViewsStore from './ViewsStore';
|
||||||
|
import CollectionGroupMembershipsStore from './CollectionGroupMembershipsStore';
|
||||||
|
|
||||||
export default class RootStore {
|
export default class RootStore {
|
||||||
apiKeys: ApiKeysStore;
|
apiKeys: ApiKeysStore;
|
||||||
auth: AuthStore;
|
auth: AuthStore;
|
||||||
collections: CollectionsStore;
|
collections: CollectionsStore;
|
||||||
|
collectionGroupMemberships: CollectionGroupMembershipsStore;
|
||||||
documents: DocumentsStore;
|
documents: DocumentsStore;
|
||||||
events: EventsStore;
|
events: EventsStore;
|
||||||
|
groups: GroupsStore;
|
||||||
|
groupMemberships: GroupMembershipsStore;
|
||||||
integrations: IntegrationsStore;
|
integrations: IntegrationsStore;
|
||||||
memberships: MembershipsStore;
|
memberships: MembershipsStore;
|
||||||
notificationSettings: NotificationSettingsStore;
|
notificationSettings: NotificationSettingsStore;
|
||||||
|
@ -36,8 +42,11 @@ export default class RootStore {
|
||||||
this.apiKeys = new ApiKeysStore(this);
|
this.apiKeys = new ApiKeysStore(this);
|
||||||
this.auth = new AuthStore(this);
|
this.auth = new AuthStore(this);
|
||||||
this.collections = new CollectionsStore(this);
|
this.collections = new CollectionsStore(this);
|
||||||
|
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
|
||||||
this.documents = new DocumentsStore(this);
|
this.documents = new DocumentsStore(this);
|
||||||
this.events = new EventsStore(this);
|
this.events = new EventsStore(this);
|
||||||
|
this.groups = new GroupsStore(this);
|
||||||
|
this.groupMemberships = new GroupMembershipsStore(this);
|
||||||
this.integrations = new IntegrationsStore(this);
|
this.integrations = new IntegrationsStore(this);
|
||||||
this.memberships = new MembershipsStore(this);
|
this.memberships = new MembershipsStore(this);
|
||||||
this.notificationSettings = new NotificationSettingsStore(this);
|
this.notificationSettings = new NotificationSettingsStore(this);
|
||||||
|
@ -52,9 +61,13 @@ export default class RootStore {
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
this.apiKeys.clear();
|
this.apiKeys.clear();
|
||||||
|
// this.auth omitted for reasons...
|
||||||
this.collections.clear();
|
this.collections.clear();
|
||||||
|
this.collectionGroupMemberships.clear();
|
||||||
this.documents.clear();
|
this.documents.clear();
|
||||||
this.events.clear();
|
this.events.clear();
|
||||||
|
this.groups.clear();
|
||||||
|
this.groupMemberships.clear();
|
||||||
this.integrations.clear();
|
this.integrations.clear();
|
||||||
this.memberships.clear();
|
this.memberships.clear();
|
||||||
this.notificationSettings.clear();
|
this.notificationSettings.clear();
|
||||||
|
@ -62,6 +75,7 @@ export default class RootStore {
|
||||||
this.policies.clear();
|
this.policies.clear();
|
||||||
this.revisions.clear();
|
this.revisions.clear();
|
||||||
this.shares.clear();
|
this.shares.clear();
|
||||||
|
// this.ui omitted to keep ui settings between sessions
|
||||||
this.users.clear();
|
this.users.clear();
|
||||||
this.views.clear();
|
this.views.clear();
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,11 +82,9 @@ export default class UsersStore extends BaseStore<User> {
|
||||||
);
|
);
|
||||||
const userIds = memberships.map(member => member.userId);
|
const userIds = memberships.map(member => member.userId);
|
||||||
const users = filter(this.orderedData, user => !userIds.includes(user.id));
|
const users = filter(this.orderedData, user => !userIds.includes(user.id));
|
||||||
if (!query) return users;
|
|
||||||
|
|
||||||
return filter(users, user =>
|
if (!query) return users;
|
||||||
user.name.toLowerCase().match(query.toLowerCase())
|
return queriedUsers(users, query);
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
inCollection = (collectionId: string, query: string) => {
|
inCollection = (collectionId: string, query: string) => {
|
||||||
|
@ -98,10 +96,31 @@ export default class UsersStore extends BaseStore<User> {
|
||||||
const users = filter(this.orderedData, user => userIds.includes(user.id));
|
const users = filter(this.orderedData, user => userIds.includes(user.id));
|
||||||
|
|
||||||
if (!query) return users;
|
if (!query) return users;
|
||||||
|
return queriedUsers(users, query);
|
||||||
|
};
|
||||||
|
|
||||||
return filter(users, user =>
|
notInGroup = (groupId: string, query: string = '') => {
|
||||||
user.name.toLowerCase().match(query.toLowerCase())
|
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) => {
|
actionOnUser = async (action: string, user: User) => {
|
||||||
|
@ -116,3 +135,9 @@ export default class UsersStore extends BaseStore<User> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queriedUsers(users, query) {
|
||||||
|
return filter(users, user =>
|
||||||
|
user.name.toLowerCase().match(query.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -119,7 +119,7 @@
|
||||||
"mobx-react": "^5.4.2",
|
"mobx-react": "^5.4.2",
|
||||||
"natural-sort": "^1.0.0",
|
"natural-sort": "^1.0.0",
|
||||||
"nodemailer": "^4.4.0",
|
"nodemailer": "^4.4.0",
|
||||||
"outline-icons": "^1.10.0",
|
"outline-icons": "^1.13.0",
|
||||||
"oy-vey": "^0.10.0",
|
"oy-vey": "^0.10.0",
|
||||||
"pg": "^6.1.5",
|
"pg": "^6.1.5",
|
||||||
"pg-hstore": "2.3.2",
|
"pg-hstore": "2.3.2",
|
||||||
|
|
|
@ -1,5 +1,13 @@
|
||||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
// 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`] = `
|
exports[`#collections.add_user should require user in team 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authorization_error",
|
"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`] = `
|
exports[`#collections.info should require authentication 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authentication_required",
|
"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`] = `
|
exports[`#collections.remove_user should require user in team 1`] = `
|
||||||
Object {
|
Object {
|
||||||
"error": "authorization_error",
|
"error": "authorization_error",
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
`;
|
|
@ -9,8 +9,18 @@ import {
|
||||||
presentUser,
|
presentUser,
|
||||||
presentPolicies,
|
presentPolicies,
|
||||||
presentMembership,
|
presentMembership,
|
||||||
|
presentGroup,
|
||||||
|
presentCollectionGroupMembership,
|
||||||
} from '../presenters';
|
} 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 { ValidationError } from '../errors';
|
||||||
import { exportCollections } from '../logistics';
|
import { exportCollections } from '../logistics';
|
||||||
import { archiveCollection, archiveCollections } from '../utils/zip';
|
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 => {
|
router.post('collections.add_user', auth(), async ctx => {
|
||||||
const { id, userId, permission = 'read_write' } = ctx.body;
|
const { id, userId, permission = 'read_write' } = ctx.body;
|
||||||
ctx.assertUuid(id, 'id is required');
|
ctx.assertUuid(id, 'id is required');
|
||||||
|
@ -302,9 +454,11 @@ router.post('collections.update', auth(), async ctx => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = ctx.state.user;
|
const user = ctx.state.user;
|
||||||
|
|
||||||
const collection = await Collection.scope({
|
const collection = await Collection.scope({
|
||||||
method: ['withMembership', user.id],
|
method: ['withMembership', user.id],
|
||||||
}).findByPk(id);
|
}).findByPk(id);
|
||||||
|
|
||||||
authorize(user, 'update', collection);
|
authorize(user, 'update', collection);
|
||||||
|
|
||||||
// we're making this collection private right now, ensure that the current
|
// 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.description = description;
|
||||||
collection.color = color;
|
collection.color = color;
|
||||||
collection.private = isPrivate;
|
collection.private = isPrivate;
|
||||||
|
|
||||||
await collection.save();
|
await collection.save();
|
||||||
|
|
||||||
await Event.create({
|
await Event.create({
|
||||||
|
@ -342,11 +497,7 @@ router.post('collections.update', auth(), async ctx => {
|
||||||
// must reload to update collection membership for correct policy calculation
|
// must reload to update collection membership for correct policy calculation
|
||||||
// if the privacy level has changed. Otherwise skip this query for speed.
|
// if the privacy level has changed. Otherwise skip this query for speed.
|
||||||
if (isPrivacyChanged) {
|
if (isPrivacyChanged) {
|
||||||
await collection.reload({
|
await collection.reload();
|
||||||
scope: {
|
|
||||||
method: ['withMembership', user.id],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
|
@ -385,6 +536,7 @@ router.post('collections.delete', auth(), async ctx => {
|
||||||
const collection = await Collection.scope({
|
const collection = await Collection.scope({
|
||||||
method: ['withMembership', user.id],
|
method: ['withMembership', user.id],
|
||||||
}).findByPk(id);
|
}).findByPk(id);
|
||||||
|
|
||||||
authorize(user, 'delete', collection);
|
authorize(user, 'delete', collection);
|
||||||
|
|
||||||
const total = await Collection.count();
|
const total = await Collection.count();
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
import TestServer from 'fetch-test-server';
|
import TestServer from 'fetch-test-server';
|
||||||
import app from '../app';
|
import app from '../app';
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { buildUser, buildCollection } from '../test/factories';
|
import { buildUser, buildGroup, buildCollection } from '../test/factories';
|
||||||
import { Collection, CollectionUser } from '../models';
|
import { Collection, CollectionUser, CollectionGroup } from '../models';
|
||||||
const server = new TestServer(app.callback());
|
const server = new TestServer(app.callback());
|
||||||
|
|
||||||
beforeEach(flushdb);
|
beforeEach(flushdb);
|
||||||
|
@ -32,7 +32,7 @@ describe('#collections.list', async () => {
|
||||||
expect(body.policies[0].abilities.read).toEqual(true);
|
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();
|
const { user, collection } = await seed();
|
||||||
await buildCollection({
|
await buildCollection({
|
||||||
private: true,
|
private: true,
|
||||||
|
@ -48,13 +48,50 @@ describe('#collections.list', async () => {
|
||||||
expect(body.data[0].id).toEqual(collection.id);
|
expect(body.data[0].id).toEqual(collection.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return private collections member of', async () => {
|
it('should return private collections actor is a member of', async () => {
|
||||||
const { user } = await seed();
|
const user = await buildUser();
|
||||||
await buildCollection({
|
await buildCollection({
|
||||||
private: true,
|
private: true,
|
||||||
teamId: user.teamId,
|
teamId: user.teamId,
|
||||||
userId: user.id,
|
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', {
|
const res = await server.post('/api/collections.list', {
|
||||||
body: { token: user.getJwtToken() },
|
body: { token: user.getJwtToken() },
|
||||||
});
|
});
|
||||||
|
@ -81,7 +118,7 @@ describe('#collections.export', async () => {
|
||||||
expect(res.status).toEqual(403);
|
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();
|
const { user, collection } = await seed();
|
||||||
collection.private = true;
|
collection.private = true;
|
||||||
await collection.save();
|
await collection.save();
|
||||||
|
@ -100,6 +137,27 @@ describe('#collections.export', async () => {
|
||||||
expect(res.status).toEqual(200);
|
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 () => {
|
it('should require authentication', async () => {
|
||||||
const res = await server.post('/api/collections.export');
|
const res = await server.post('/api/collections.export');
|
||||||
const body = await res.json();
|
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 () => {
|
describe('#collections.remove_user', async () => {
|
||||||
it('should remove user from collection', async () => {
|
it('should remove user from collection', async () => {
|
||||||
const user = await buildUser();
|
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 () => {
|
describe('#collections.memberships', async () => {
|
||||||
it('should return members in private collection', async () => {
|
it('should return members in private collection', async () => {
|
||||||
const { collection, user } = await seed();
|
const { collection, user } = await seed();
|
||||||
|
@ -641,6 +987,29 @@ describe('#collections.update', async () => {
|
||||||
expect(body.policies.length).toBe(1);
|
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 () => {
|
it('does not allow editing by read-only collection user', async () => {
|
||||||
const { user, collection } = await seed();
|
const { user, collection } = await seed();
|
||||||
collection.private = true;
|
collection.private = true;
|
||||||
|
@ -704,4 +1073,30 @@ describe('#collections.delete', async () => {
|
||||||
expect(res.status).toEqual(200);
|
expect(res.status).toEqual(200);
|
||||||
expect(body.success).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -121,6 +121,7 @@ router.post('documents.pinned', auth(), pagination(), async ctx => {
|
||||||
const collection = await Collection.scope({
|
const collection = await Collection.scope({
|
||||||
method: ['withMembership', user.id],
|
method: ['withMembership', user.id],
|
||||||
}).findByPk(collectionId);
|
}).findByPk(collectionId);
|
||||||
|
|
||||||
authorize(user, 'read', collection);
|
authorize(user, 'read', collection);
|
||||||
|
|
||||||
const starredScope = { method: ['withStarred', user.id] };
|
const starredScope = { method: ['withStarred', user.id] };
|
||||||
|
|
|
@ -47,9 +47,12 @@ describe('#documents.info', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not return published document in collection not a member of', async () => {
|
it('should not return published document in collection not a member of', async () => {
|
||||||
const { user, document, collection } = await seed();
|
const user = await buildUser();
|
||||||
collection.private = true;
|
const collection = await buildCollection({
|
||||||
await collection.save();
|
private: true,
|
||||||
|
teamId: user.teamId,
|
||||||
|
});
|
||||||
|
const document = await buildDocument({ collectionId: collection.id });
|
||||||
|
|
||||||
const res = await server.post('/api/documents.info', {
|
const res = await server.post('/api/documents.info', {
|
||||||
body: { token: user.getJwtToken(), id: document.id },
|
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 () => {
|
it('should not return pinned documents in private collections not a member of', async () => {
|
||||||
const { user, collection } = await seed();
|
const collection = await buildCollection({
|
||||||
collection.private = true;
|
private: true,
|
||||||
await collection.save();
|
});
|
||||||
|
|
||||||
|
const user = await buildUser({ teamId: collection.teamId });
|
||||||
|
|
||||||
const res = await server.post('/api/documents.pinned', {
|
const res = await server.post('/api/documents.pinned', {
|
||||||
body: { token: user.getJwtToken(), collectionId: collection.id },
|
body: { token: user.getJwtToken(), collectionId: collection.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toEqual(403);
|
expect(res.status).toEqual(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -12,6 +12,7 @@ import views from './views';
|
||||||
import hooks from './hooks';
|
import hooks from './hooks';
|
||||||
import apiKeys from './apiKeys';
|
import apiKeys from './apiKeys';
|
||||||
import shares from './shares';
|
import shares from './shares';
|
||||||
|
import groups from './groups';
|
||||||
import team from './team';
|
import team from './team';
|
||||||
import integrations from './integrations';
|
import integrations from './integrations';
|
||||||
import notificationSettings from './notificationSettings';
|
import notificationSettings from './notificationSettings';
|
||||||
|
@ -51,6 +52,8 @@ router.use('/', integrations.routes());
|
||||||
router.use('/', notificationSettings.routes());
|
router.use('/', notificationSettings.routes());
|
||||||
router.use('/', attachments.routes());
|
router.use('/', attachments.routes());
|
||||||
router.use('/', utils.routes());
|
router.use('/', utils.routes());
|
||||||
|
router.use('/', groups.routes());
|
||||||
|
|
||||||
router.post('*', ctx => {
|
router.post('*', ctx => {
|
||||||
ctx.throw(new NotFoundError('Endpoint not found'));
|
ctx.throw(new NotFoundError('Endpoint not found'));
|
||||||
});
|
});
|
||||||
|
|
|
@ -9,7 +9,6 @@ import {
|
||||||
publicS3Endpoint,
|
publicS3Endpoint,
|
||||||
makeCredential,
|
makeCredential,
|
||||||
} from '../utils/s3';
|
} from '../utils/s3';
|
||||||
import { ValidationError } from '../errors';
|
|
||||||
import { Attachment, Event, User, Team } from '../models';
|
import { Attachment, Event, User, Team } from '../models';
|
||||||
import auth from '../middlewares/authentication';
|
import auth from '../middlewares/authentication';
|
||||||
import pagination from './middlewares/pagination';
|
import pagination from './middlewares/pagination';
|
||||||
|
@ -177,11 +176,7 @@ router.post('users.demote', auth(), async ctx => {
|
||||||
authorize(ctx.state.user, 'demote', user);
|
authorize(ctx.state.user, 'demote', user);
|
||||||
|
|
||||||
const team = await Team.findByPk(teamId);
|
const team = await Team.findByPk(teamId);
|
||||||
try {
|
|
||||||
await team.removeAdmin(user);
|
await team.removeAdmin(user);
|
||||||
} catch (err) {
|
|
||||||
throw new ValidationError(err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Event.create({
|
await Event.create({
|
||||||
name: 'users.demote',
|
name: 'users.demote',
|
||||||
|
@ -207,11 +202,7 @@ router.post('users.suspend', auth(), async ctx => {
|
||||||
authorize(ctx.state.user, 'suspend', user);
|
authorize(ctx.state.user, 'suspend', user);
|
||||||
|
|
||||||
const team = await Team.findByPk(teamId);
|
const team = await Team.findByPk(teamId);
|
||||||
try {
|
|
||||||
await team.suspendUser(user, admin);
|
await team.suspendUser(user, admin);
|
||||||
} catch (err) {
|
|
||||||
throw new ValidationError(err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Event.create({
|
await Event.create({
|
||||||
name: 'users.suspend',
|
name: 'users.suspend',
|
||||||
|
@ -278,12 +269,7 @@ router.post('users.delete', auth(), async ctx => {
|
||||||
if (id) user = await User.findByPk(id);
|
if (id) user = await User.findByPk(id);
|
||||||
authorize(ctx.state.user, 'delete', user);
|
authorize(ctx.state.user, 'delete', user);
|
||||||
|
|
||||||
try {
|
|
||||||
await user.destroy();
|
await user.destroy();
|
||||||
} catch (err) {
|
|
||||||
throw new ValidationError(err.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Event.create({
|
await Event.create({
|
||||||
name: 'users.delete',
|
name: 'users.delete',
|
||||||
actorId: user.id,
|
actorId: user.id,
|
||||||
|
|
|
@ -78,6 +78,33 @@ export type CollectionEvent =
|
||||||
collectionId: string,
|
collectionId: string,
|
||||||
teamId: string,
|
teamId: string,
|
||||||
actorId: 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 = {
|
export type IntegrationEvent = {
|
||||||
|
@ -91,7 +118,8 @@ export type Event =
|
||||||
| UserEvent
|
| UserEvent
|
||||||
| DocumentEvent
|
| DocumentEvent
|
||||||
| CollectionEvent
|
| CollectionEvent
|
||||||
| IntegrationEvent;
|
| IntegrationEvent
|
||||||
|
| GroupEvent;
|
||||||
|
|
||||||
const globalEventsQueue = createQueue('global events');
|
const globalEventsQueue = createQueue('global events');
|
||||||
const serviceEventsQueue = createQueue('service events');
|
const serviceEventsQueue = createQueue('service events');
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
};
|
|
@ -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");
|
||||||
|
}
|
||||||
|
};
|
|
@ -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");
|
||||||
|
}
|
||||||
|
};
|
|
@ -12,6 +12,7 @@ const ApiKey = sequelize.define(
|
||||||
},
|
},
|
||||||
name: DataTypes.STRING,
|
name: DataTypes.STRING,
|
||||||
secret: { type: DataTypes.STRING, unique: true },
|
secret: { type: DataTypes.STRING, unique: true },
|
||||||
|
// TODO: remove this, as it's redundant with associate below
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { find, remove } from 'lodash';
|
import { find, concat, remove, uniq } from 'lodash';
|
||||||
import slug from 'slug';
|
import slug from 'slug';
|
||||||
import randomstring from 'randomstring';
|
import randomstring from 'randomstring';
|
||||||
import { DataTypes, sequelize } from '../sequelize';
|
import { DataTypes, sequelize } from '../sequelize';
|
||||||
|
@ -59,11 +59,21 @@ Collection.associate = models => {
|
||||||
foreignKey: 'collectionId',
|
foreignKey: 'collectionId',
|
||||||
onDelete: 'cascade',
|
onDelete: 'cascade',
|
||||||
});
|
});
|
||||||
|
Collection.hasMany(models.CollectionGroup, {
|
||||||
|
as: 'collectionGroupMemberships',
|
||||||
|
foreignKey: 'collectionId',
|
||||||
|
onDelete: 'cascade',
|
||||||
|
});
|
||||||
Collection.belongsToMany(models.User, {
|
Collection.belongsToMany(models.User, {
|
||||||
as: 'users',
|
as: 'users',
|
||||||
through: models.CollectionUser,
|
through: models.CollectionUser,
|
||||||
foreignKey: 'collectionId',
|
foreignKey: 'collectionId',
|
||||||
});
|
});
|
||||||
|
Collection.belongsToMany(models.Group, {
|
||||||
|
as: 'groups',
|
||||||
|
through: models.CollectionGroup,
|
||||||
|
foreignKey: 'collectionId',
|
||||||
|
});
|
||||||
Collection.belongsTo(models.User, {
|
Collection.belongsTo(models.User, {
|
||||||
as: 'user',
|
as: 'user',
|
||||||
foreignKey: 'creatorId',
|
foreignKey: 'creatorId',
|
||||||
|
@ -79,8 +89,66 @@ Collection.associate = models => {
|
||||||
where: { userId },
|
where: { userId },
|
||||||
required: false,
|
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) => {
|
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
|
// Instance methods
|
||||||
|
|
||||||
Collection.prototype.addDocumentToStructure = async function(
|
Collection.prototype.addDocumentToStructure = async function(
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
/* eslint-disable flowtype/require-valid-file-annotation */
|
/* eslint-disable flowtype/require-valid-file-annotation */
|
||||||
import { flushdb, seed } from '../test/support';
|
import { flushdb, seed } from '../test/support';
|
||||||
import { Collection, Document } from '../models';
|
import { Collection, Document } from '../models';
|
||||||
|
import {
|
||||||
|
buildUser,
|
||||||
|
buildGroup,
|
||||||
|
buildCollection,
|
||||||
|
buildTeam,
|
||||||
|
} from '../test/factories';
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
|
|
||||||
beforeEach(flushdb);
|
beforeEach(flushdb);
|
||||||
|
@ -229,3 +235,44 @@ describe('#removeDocument', () => {
|
||||||
expect(collectionDocuments.count).toBe(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -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;
|
|
@ -172,16 +172,10 @@ Document.associate = models => {
|
||||||
return {
|
return {
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: models.Collection,
|
model: models.Collection.scope({
|
||||||
|
method: ['withMembership', userId],
|
||||||
|
}),
|
||||||
as: 'collection',
|
as: 'collection',
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: models.CollectionUser,
|
|
||||||
as: 'memberships',
|
|
||||||
where: { userId },
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
@ -81,10 +81,15 @@ Event.AUDIT_EVENTS = [
|
||||||
'documents.delete',
|
'documents.delete',
|
||||||
'shares.create',
|
'shares.create',
|
||||||
'shares.revoke',
|
'shares.revoke',
|
||||||
|
'groups.create',
|
||||||
|
'groups.update',
|
||||||
|
'groups.delete',
|
||||||
'collections.create',
|
'collections.create',
|
||||||
'collections.update',
|
'collections.update',
|
||||||
'collections.add_user',
|
'collections.add_user',
|
||||||
'collections.remove_user',
|
'collections.remove_user',
|
||||||
|
'collections.add_group',
|
||||||
|
'collections.remove_group',
|
||||||
'collections.delete',
|
'collections.delete',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -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;
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
|
@ -11,6 +11,7 @@ import {
|
||||||
RESERVED_SUBDOMAINS,
|
RESERVED_SUBDOMAINS,
|
||||||
} from '../../shared/utils/domains';
|
} from '../../shared/utils/domains';
|
||||||
import parseTitle from '../../shared/utils/parseTitle';
|
import parseTitle from '../../shared/utils/parseTitle';
|
||||||
|
import { ValidationError } from '../errors';
|
||||||
|
|
||||||
import Collection from './Collection';
|
import Collection from './Collection';
|
||||||
import Document from './Document';
|
import Document from './Document';
|
||||||
|
@ -181,13 +182,13 @@ Team.prototype.removeAdmin = async function(user: User) {
|
||||||
if (res.count >= 1) {
|
if (res.count >= 1) {
|
||||||
return user.update({ isAdmin: false });
|
return user.update({ isAdmin: false });
|
||||||
} else {
|
} 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) {
|
Team.prototype.suspendUser = async function(user: User, admin: User) {
|
||||||
if (user.id === admin.id)
|
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({
|
return user.update({
|
||||||
suspendedById: admin.id,
|
suspendedById: admin.id,
|
||||||
suspendedAt: new Date(),
|
suspendedAt: new Date(),
|
||||||
|
|
|
@ -3,6 +3,7 @@ import crypto from 'crypto';
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
import JWT from 'jsonwebtoken';
|
import JWT from 'jsonwebtoken';
|
||||||
import subMinutes from 'date-fns/sub_minutes';
|
import subMinutes from 'date-fns/sub_minutes';
|
||||||
|
import { ValidationError } from '../errors';
|
||||||
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
|
import { DataTypes, sequelize, encryptedFields } from '../sequelize';
|
||||||
import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3';
|
import { publicS3Endpoint, uploadToS3FromUrl } from '../utils/s3';
|
||||||
import { sendEmail } from '../mailer';
|
import { sendEmail } from '../mailer';
|
||||||
|
@ -71,22 +72,22 @@ User.associate = models => {
|
||||||
|
|
||||||
// Instance methods
|
// Instance methods
|
||||||
User.prototype.collectionIds = async function(paranoid: boolean = true) {
|
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'],
|
attributes: ['id', 'private'],
|
||||||
where: { teamId: this.teamId },
|
where: { teamId: this.teamId },
|
||||||
include: [
|
|
||||||
{
|
|
||||||
model: User,
|
|
||||||
as: 'users',
|
|
||||||
where: { id: this.id },
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
paranoid,
|
paranoid,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter collections that are private and don't have an association
|
return collectionStubs
|
||||||
return models.filter(c => !c.private || c.users.length).map(c => c.id);
|
.filter(
|
||||||
|
c =>
|
||||||
|
!c.private ||
|
||||||
|
c.memberships.length > 0 ||
|
||||||
|
c.collectionGroupMemberships.length > 0
|
||||||
|
)
|
||||||
|
.map(c => c.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
User.prototype.updateActiveAt = function(ip) {
|
User.prototype.updateActiveAt = function(ip) {
|
||||||
|
@ -186,7 +187,7 @@ const checkLastAdmin = async model => {
|
||||||
const adminCount = await User.count({ where: { isAdmin: true, teamId } });
|
const adminCount = await User.count({ where: { isAdmin: true, teamId } });
|
||||||
|
|
||||||
if (userCount > 1 && adminCount <= 1) {
|
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.'
|
'Cannot delete account as only admin. Please transfer admin permissions to another user and try again.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,12 @@ import Authentication from './Authentication';
|
||||||
import Backlink from './Backlink';
|
import Backlink from './Backlink';
|
||||||
import Collection from './Collection';
|
import Collection from './Collection';
|
||||||
import CollectionUser from './CollectionUser';
|
import CollectionUser from './CollectionUser';
|
||||||
|
import CollectionGroup from './CollectionGroup';
|
||||||
import Document from './Document';
|
import Document from './Document';
|
||||||
import Event from './Event';
|
import Event from './Event';
|
||||||
import Integration from './Integration';
|
import Integration from './Integration';
|
||||||
|
import Group from './Group';
|
||||||
|
import GroupUser from './GroupUser';
|
||||||
import Notification from './Notification';
|
import Notification from './Notification';
|
||||||
import NotificationSetting from './NotificationSetting';
|
import NotificationSetting from './NotificationSetting';
|
||||||
import Revision from './Revision';
|
import Revision from './Revision';
|
||||||
|
@ -23,9 +26,12 @@ const models = {
|
||||||
Authentication,
|
Authentication,
|
||||||
Backlink,
|
Backlink,
|
||||||
Collection,
|
Collection,
|
||||||
|
CollectionGroup,
|
||||||
CollectionUser,
|
CollectionUser,
|
||||||
Document,
|
Document,
|
||||||
Event,
|
Event,
|
||||||
|
Group,
|
||||||
|
GroupUser,
|
||||||
Integration,
|
Integration,
|
||||||
Notification,
|
Notification,
|
||||||
NotificationSetting,
|
NotificationSetting,
|
||||||
|
@ -50,9 +56,12 @@ export {
|
||||||
Authentication,
|
Authentication,
|
||||||
Backlink,
|
Backlink,
|
||||||
Collection,
|
Collection,
|
||||||
|
CollectionGroup,
|
||||||
CollectionUser,
|
CollectionUser,
|
||||||
Document,
|
Document,
|
||||||
Event,
|
Event,
|
||||||
|
Group,
|
||||||
|
GroupUser,
|
||||||
Integration,
|
Integration,
|
||||||
Notification,
|
Notification,
|
||||||
NotificationSetting,
|
NotificationSetting,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// @flow
|
// @flow
|
||||||
import invariant from 'invariant';
|
import invariant from 'invariant';
|
||||||
import policy from './policy';
|
import policy from './policy';
|
||||||
|
import { concat, some } from 'lodash';
|
||||||
import { Collection, User } from '../models';
|
import { Collection, User } from '../models';
|
||||||
import { AdminRequiredError } from '../errors';
|
import { AdminRequiredError } from '../errors';
|
||||||
|
|
||||||
|
@ -11,11 +12,20 @@ allow(User, 'create', Collection);
|
||||||
allow(User, ['read', 'export'], Collection, (user, collection) => {
|
allow(User, ['read', 'export'], Collection, (user, collection) => {
|
||||||
if (!collection || user.teamId !== collection.teamId) return false;
|
if (!collection || user.teamId !== collection.teamId) return false;
|
||||||
|
|
||||||
if (
|
if (collection.private) {
|
||||||
collection.private &&
|
invariant(
|
||||||
(!collection.memberships || !collection.memberships.length)
|
collection.memberships,
|
||||||
) {
|
'membership should be preloaded, did you forget withMembership scope?'
|
||||||
return false;
|
);
|
||||||
|
|
||||||
|
const allMemberships = concat(
|
||||||
|
collection.memberships,
|
||||||
|
collection.collectionGroupMemberships
|
||||||
|
);
|
||||||
|
|
||||||
|
return some(allMemberships, m =>
|
||||||
|
['read', 'read_write', 'maintainer'].includes(m.permission)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -29,10 +39,14 @@ allow(User, ['publish', 'update'], Collection, (user, collection) => {
|
||||||
collection.memberships,
|
collection.memberships,
|
||||||
'membership should be preloaded, did you forget withMembership scope?'
|
'membership should be preloaded, did you forget withMembership scope?'
|
||||||
);
|
);
|
||||||
if (!collection.memberships.length) return false;
|
|
||||||
|
|
||||||
return ['read_write', 'maintainer'].includes(
|
const allMemberships = concat(
|
||||||
collection.memberships[0].permission
|
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,
|
collection.memberships,
|
||||||
'membership should be preloaded, did you forget withMembership scope?'
|
'membership should be preloaded, did you forget withMembership scope?'
|
||||||
);
|
);
|
||||||
if (!collection.memberships.length) return false;
|
const allMemberships = concat(
|
||||||
|
collection.memberships,
|
||||||
|
collection.collectionGroupMemberships
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
return some(allMemberships, m =>
|
||||||
!['read_write', 'maintainer'].includes(
|
['read_write', 'maintainer'].includes(m.permission)
|
||||||
collection.memberships[0].permission
|
);
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isAdmin) return true;
|
if (user.isAdmin) return true;
|
||||||
|
|
|
@ -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;
|
||||||
|
});
|
|
@ -1,5 +1,5 @@
|
||||||
// @flow
|
// @flow
|
||||||
import { Team, User, Collection, Document } from '../models';
|
import { Team, User, Collection, Document, Group } from '../models';
|
||||||
import policy from './policy';
|
import policy from './policy';
|
||||||
import './apiKey';
|
import './apiKey';
|
||||||
import './collection';
|
import './collection';
|
||||||
|
@ -9,6 +9,7 @@ import './notificationSetting';
|
||||||
import './share';
|
import './share';
|
||||||
import './user';
|
import './user';
|
||||||
import './team';
|
import './team';
|
||||||
|
import './group';
|
||||||
|
|
||||||
const { can, abilities } = policy;
|
const { can, abilities } = policy;
|
||||||
|
|
||||||
|
@ -23,7 +24,7 @@ type Policy = {
|
||||||
*/
|
*/
|
||||||
export function serialize(
|
export function serialize(
|
||||||
model: User,
|
model: User,
|
||||||
target: Team | Collection | Document
|
target: Team | Collection | Document | Group
|
||||||
): Policy {
|
): Policy {
|
||||||
let output = {};
|
let output = {};
|
||||||
|
|
||||||
|
|
|
@ -22,6 +22,12 @@ allow(User, 'invite', Team, user => {
|
||||||
return false;
|
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) => {
|
allow(User, ['update', 'export'], Team, (user, team) => {
|
||||||
if (!team || user.teamId !== team.id) return false;
|
if (!team || user.teamId !== team.id) return false;
|
||||||
if (user.isAdmin) return true;
|
if (user.isAdmin) return true;
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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),
|
||||||
|
};
|
||||||
|
};
|
|
@ -13,6 +13,9 @@ import presentMembership from './membership';
|
||||||
import presentNotificationSetting from './notificationSetting';
|
import presentNotificationSetting from './notificationSetting';
|
||||||
import presentSlackAttachment from './slackAttachment';
|
import presentSlackAttachment from './slackAttachment';
|
||||||
import presentPolicies from './policy';
|
import presentPolicies from './policy';
|
||||||
|
import presentGroup from './group';
|
||||||
|
import presentGroupMembership from './groupMembership';
|
||||||
|
import presentCollectionGroupMembership from './collectionGroupMembership';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
presentUser,
|
presentUser,
|
||||||
|
@ -24,9 +27,12 @@ export {
|
||||||
presentApiKey,
|
presentApiKey,
|
||||||
presentShare,
|
presentShare,
|
||||||
presentTeam,
|
presentTeam,
|
||||||
|
presentGroup,
|
||||||
presentIntegration,
|
presentIntegration,
|
||||||
presentMembership,
|
presentMembership,
|
||||||
presentNotificationSetting,
|
presentNotificationSetting,
|
||||||
presentSlackAttachment,
|
presentSlackAttachment,
|
||||||
presentPolicies,
|
presentPolicies,
|
||||||
|
presentGroupMembership,
|
||||||
|
presentCollectionGroupMembership,
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,6 +12,7 @@ export const DataTypes = Sequelize;
|
||||||
export const Op = Sequelize.Op;
|
export const Op = Sequelize.Op;
|
||||||
|
|
||||||
export const sequelize = new Sequelize(process.env.DATABASE_URL, {
|
export const sequelize = new Sequelize(process.env.DATABASE_URL, {
|
||||||
|
// logging: console.log,
|
||||||
logging: debug('sql'),
|
logging: debug('sql'),
|
||||||
typeValidation: true,
|
typeValidation: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,7 +1,15 @@
|
||||||
// @flow
|
// @flow
|
||||||
import type { Event } from '../events';
|
import type { Event } from '../events';
|
||||||
import { Document, Collection } from '../models';
|
import {
|
||||||
|
Document,
|
||||||
|
Collection,
|
||||||
|
Group,
|
||||||
|
CollectionGroup,
|
||||||
|
GroupUser,
|
||||||
|
} from '../models';
|
||||||
import { socketio } from '../';
|
import { socketio } from '../';
|
||||||
|
import { Op } from '../sequelize';
|
||||||
|
import subHours from 'date-fns/sub_hours';
|
||||||
|
|
||||||
export default class Websockets {
|
export default class Websockets {
|
||||||
async on(event: Event) {
|
async on(event: Event) {
|
||||||
|
@ -206,19 +214,261 @@ export default class Websockets {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
case 'collections.remove_user': {
|
case 'collections.remove_user': {
|
||||||
|
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
|
// let everyone with access to the collection know a user was removed
|
||||||
socketio.to(`collection-${event.collectionId}`).emit(event.name, {
|
socketio
|
||||||
|
.to(`collection-${event.collectionId}`)
|
||||||
|
.emit('collections.remove_user', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
userId: event.userId,
|
userId: event.userId,
|
||||||
collectionId: event.collectionId,
|
collectionId: event.collectionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// tell any user clients to disconnect from the websocket channel for the collection
|
// tell any user clients to disconnect from the websocket channel for the collection
|
||||||
return socketio.to(`user-${event.userId}`).emit('leave', {
|
socketio.to(`user-${event.userId}`).emit('leave', {
|
||||||
event: event.name,
|
event: event.name,
|
||||||
collectionId: event.collectionId,
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
return socketio.to(`team-${group.teamId}`).emit('entities', {
|
||||||
|
event: event.name,
|
||||||
|
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:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,8 @@ import {
|
||||||
Event,
|
Event,
|
||||||
Document,
|
Document,
|
||||||
Collection,
|
Collection,
|
||||||
|
Group,
|
||||||
|
GroupUser,
|
||||||
Attachment,
|
Attachment,
|
||||||
} from '../models';
|
} from '../models';
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
|
@ -72,12 +74,12 @@ export async function buildCollection(overrides: Object = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!overrides.userId) {
|
if (!overrides.userId) {
|
||||||
const user = await buildUser();
|
const user = await buildUser({ teamId: overrides.teamId });
|
||||||
overrides.userId = user.id;
|
overrides.userId = user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Collection.create({
|
return Collection.create({
|
||||||
name: 'Test Collection',
|
name: `Test Collection ${count}`,
|
||||||
description: 'Test collection description',
|
description: 'Test collection description',
|
||||||
creatorId: overrides.userId,
|
creatorId: overrides.userId,
|
||||||
type: 'atlas',
|
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 = {}) {
|
export async function buildDocument(overrides: Object = {}) {
|
||||||
count++;
|
count++;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
// @flow
|
// @flow
|
||||||
|
|
||||||
export const USER_PRESENCE_INTERVAL = 5000;
|
export const USER_PRESENCE_INTERVAL = 5000;
|
||||||
|
export const MAX_AVATAR_DISPLAY = 6;
|
||||||
|
|
|
@ -72,3 +72,7 @@ export function signin(service: string = 'slack'): string {
|
||||||
export function settings(): string {
|
export function settings(): string {
|
||||||
return `/settings`;
|
return `/settings`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function groupSettings(): string {
|
||||||
|
return `/settings/groups`;
|
||||||
|
}
|
||||||
|
|
|
@ -7160,6 +7160,11 @@ outline-icons@^1.10.0:
|
||||||
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.10.0.tgz#3c8e6957429e2b04c9d0fc72fe72e473813ce5bd"
|
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-1.10.0.tgz#3c8e6957429e2b04c9d0fc72fe72e473813ce5bd"
|
||||||
integrity sha512-1o3SnjzawEIh+QkZ6GHxPckuV+Tk5m5R2tjGY0CtosF3YA7JbgQ2jQrZdQsrqLzLa1j07f1bTEbAjGdbnunLpg==
|
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:
|
oy-vey@^0.10.0:
|
||||||
version "0.10.0"
|
version "0.10.0"
|
||||||
resolved "https://registry.yarnpkg.com/oy-vey/-/oy-vey-0.10.0.tgz#16160f837f0ea3d0340adfc2377ba93d1ed9ce76"
|
resolved "https://registry.yarnpkg.com/oy-vey/-/oy-vey-0.10.0.tgz#16160f837f0ea3d0340adfc2377ba93d1ed9ce76"
|
||||||
|
|
Reference in New Issue