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:
Nan Yu 2020-03-14 20:48:32 -07:00 committed by GitHub
parent 6c451a34d4
commit 142303b3de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
81 changed files with 4259 additions and 257 deletions

View File

@ -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%;

View File

@ -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;

View File

@ -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;

View File

@ -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));

View File

@ -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));

View File

@ -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}
/>
)}
&nbsp;
{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);

View File

@ -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`

View File

@ -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 {

View File

@ -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();
} }

View File

@ -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 />}

View File

@ -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',

View File

@ -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 && (

102
app/menus/GroupMenu.js Normal file
View File

@ -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));

View File

@ -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;

17
app/models/Group.js Normal file
View File

@ -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;

View File

@ -0,0 +1,10 @@
// @flow
import BaseModel from './BaseModel';
class GroupMembership extends BaseModel {
id: string;
userId: string;
groupId: string;
}
export default GroupMembership;

View File

@ -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} />

View File

@ -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}
> >

View File

@ -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;

View File

@ -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>
Cant find the group youre 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
);

View File

@ -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>
)} )}
<Subheading>Members</Subheading> {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>
)}
<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);

View File

@ -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;

View File

@ -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 &&

View File

@ -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 ? (

59
app/scenes/GroupDelete.js Normal file
View File

@ -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…' : 'Im sure  Delete'}
</Button>
</form>
</Flex>
);
}
}
export default inject('ui')(withRouter(GroupDelete));

71
app/scenes/GroupEdit.js Normal file
View File

@ -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));

View File

@ -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 whos 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
);

View File

@ -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
);

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,3 @@
// @flow
import GroupMembers from './GroupMembers';
export default GroupMembers;

91
app/scenes/GroupNew.js Normal file
View File

@ -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>Youll 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));

View File

@ -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);

View File

@ -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 && ( <Subheading>Shared Documents</Subheading>
<React.Fragment> {hasSharedDocuments ? (
<Subheading>Shared Documents</Subheading> <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>
); );

View File

@ -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>

View File

@ -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);
}
});
};
}

View File

@ -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);
};
}

77
app/stores/GroupsStore.js Normal file
View File

@ -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())
);
}

View File

@ -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();
} }

View File

@ -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())
);
}

View File

@ -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",

View File

@ -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",

View File

@ -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,
}
`;

View File

@ -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();

View File

@ -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);
});
}); });

View File

@ -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] };

View File

@ -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);
}); });

291
server/api/groups.js Normal file
View File

@ -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;

460
server/api/groups.test.js Normal file
View File

@ -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();
});
});

View File

@ -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'));
}); });

View File

@ -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,

View File

@ -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');

View File

@ -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");
}
};

View File

@ -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");
}
};

View File

@ -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");
}
};

View File

@ -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,

View File

@ -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(

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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,
},
],
}, },
], ],
}; };
@ -269,7 +263,7 @@ Document.searchForTeam = async (
"collectionId" IN(:collectionIds) AND "collectionId" IN(:collectionIds) AND
"deletedAt" IS NULL AND "deletedAt" IS NULL AND
"publishedAt" IS NOT NULL "publishedAt" IS NOT NULL
ORDER BY ORDER BY
"searchRanking" DESC, "searchRanking" DESC,
"updatedAt" DESC "updatedAt" DESC
LIMIT :limit LIMIT :limit
@ -356,8 +350,8 @@ Document.searchForUser = async (
options.includeDrafts options.includeDrafts
? '("publishedAt" IS NOT NULL OR "createdById" = :userId)' ? '("publishedAt" IS NOT NULL OR "createdById" = :userId)'
: '"publishedAt" IS NOT NULL' : '"publishedAt" IS NOT NULL'
} }
ORDER BY ORDER BY
"searchRanking" DESC, "searchRanking" DESC,
"updatedAt" DESC "updatedAt" DESC
LIMIT :limit LIMIT :limit

View File

@ -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',
]; ];

83
server/models/Group.js Normal file
View File

@ -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;

View File

@ -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);
});
});

View File

@ -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;

View File

@ -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(),

View File

@ -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.'
); );
} }

View File

@ -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,

View File

@ -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;

26
server/policies/group.js Normal file
View File

@ -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;
});

View File

@ -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;
@ -17,13 +18,13 @@ type Policy = {
}; };
/* /*
* Given a user and a model output an object which describes the actions the * Given a user and a model output an object which describes the actions the
* user may take against the model. This serialized policy is used for testing * user may take against the model. This serialized policy is used for testing
* and sent in API responses to allow clients to adjust which UI is displayed. * and sent in API responses to allow clients to adjust which UI is displayed.
*/ */
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 = {};

View File

@ -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;

View File

@ -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,
};
};

View File

@ -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,
};
}

View File

@ -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),
};
};

View File

@ -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,
}; };

View File

@ -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,
}); });

View File

@ -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': {
// let everyone with access to the collection know a user was removed const membershipUserIds = await Collection.membershipUserIds(
socketio.to(`collection-${event.collectionId}`).emit(event.name, { event.collectionId
event: event.name, );
userId: event.userId,
collectionId: event.collectionId, if (membershipUserIds.includes(event.userId)) {
// Even though we just removed a user from the collection
// the user still has access through some means
// treat this like an add, so that the client re-syncs policies
socketio.to(`user-${event.userId}`).emit('collections.add_user', {
event: 'collections.add_user',
userId: event.userId,
collectionId: event.collectionId,
});
} else {
// let everyone with access to the collection know a user was removed
socketio
.to(`collection-${event.collectionId}`)
.emit('collections.remove_user', {
event: event.name,
userId: event.userId,
collectionId: event.collectionId,
});
// tell any user clients to disconnect from the websocket channel for the collection
socketio.to(`user-${event.userId}`).emit('leave', {
event: event.name,
collectionId: event.collectionId,
});
}
return;
}
case 'collections.add_group': {
const group = await Group.findByPk(event.data.groupId);
// the users being added are not yet in the websocket channel for the collection
// so they need to be notified separately
for (const groupMembership of group.groupMemberships) {
socketio
.to(`user-${groupMembership.userId}`)
.emit('collections.add_user', {
event: event.name,
userId: groupMembership.userId,
collectionId: event.collectionId,
});
// tell any user clients to connect to the websocket channel for the collection
socketio.to(`user-${groupMembership.userId}`).emit('join', {
event: event.name,
collectionId: event.collectionId,
});
}
return;
}
case 'collections.remove_group': {
const group = await Group.findByPk(event.data.groupId);
const membershipUserIds = await Collection.membershipUserIds(
event.collectionId
);
for (const groupMembership of group.groupMemberships) {
if (membershipUserIds.includes(groupMembership.userId)) {
// the user still has access through some means...
// treat this like an add, so that the client re-syncs policies
socketio
.to(`user-${groupMembership.userId}`)
.emit('collections.add_user', {
event: event.name,
userId: groupMembership.userId,
collectionId: event.collectionId,
});
} else {
// let users in the channel know they were removed
socketio
.to(`user-${groupMembership.userId}`)
.emit('collections.remove_user', {
event: event.name,
userId: groupMembership.userId,
collectionId: event.collectionId,
});
// tell any user clients to disconnect to the websocket channel for the collection
socketio.to(`user-${groupMembership.userId}`).emit('leave', {
event: event.name,
collectionId: event.collectionId,
});
}
}
return;
}
case 'groups.create':
case 'groups.update': {
const group = await Group.findByPk(event.modelId, {
paranoid: false,
}); });
// tell any user clients to disconnect from the websocket channel for the collection return socketio.to(`team-${group.teamId}`).emit('entities', {
return socketio.to(`user-${event.userId}`).emit('leave', {
event: event.name, event: event.name,
collectionId: event.collectionId, groupIds: [
{
id: group.id,
updatedAt: group.updatedAt,
},
],
}); });
} }
case 'groups.add_user': {
// do an add user for every collection that the group is a part of
const collectionGroupMemberships = await CollectionGroup.findAll({
where: { groupId: event.modelId },
});
for (const collectionGroup of collectionGroupMemberships) {
// the user being added isn't yet in the websocket channel for the collection
// so they need to be notified separately
socketio.to(`user-${event.userId}`).emit('collections.add_user', {
event: event.name,
userId: event.userId,
collectionId: collectionGroup.collectionId,
});
// let everyone with access to the collection know a user was added
socketio
.to(`collection-${collectionGroup.collectionId}`)
.emit('collections.add_user', {
event: event.name,
userId: event.userId,
collectionId: collectionGroup.collectionId,
});
// tell any user clients to connect to the websocket channel for the collection
return socketio.to(`user-${event.userId}`).emit('join', {
event: event.name,
collectionId: collectionGroup.collectionId,
});
}
return;
}
case 'groups.remove_user': {
const collectionGroupMemberships = await CollectionGroup.findAll({
where: { groupId: event.modelId },
});
for (const collectionGroup of collectionGroupMemberships) {
// if the user has any memberships remaining on the collection
// we need to emit add instead of remove
const collection = await Collection.scope({
method: ['withMembership', event.userId],
}).findByPk(collectionGroup.collectionId);
const hasMemberships =
collection.memberships.length > 0 ||
collection.collectionGroupMemberships.length > 0;
if (hasMemberships) {
// the user still has access through some means...
// treat this like an add, so that the client re-syncs policies
socketio.to(`user-${event.userId}`).emit('collections.add_user', {
event: event.name,
userId: event.userId,
collectionId: collectionGroup.collectionId,
});
} else {
// let everyone with access to the collection know a user was removed
socketio
.to(`collection-${collectionGroup.collectionId}`)
.emit('collections.remove_user', {
event: event.name,
userId: event.userId,
collectionId: collectionGroup.collectionId,
});
// tell any user clients to disconnect from the websocket channel for the collection
socketio.to(`user-${event.userId}`).emit('leave', {
event: event.name,
collectionId: collectionGroup.collectionId,
});
}
}
return;
}
case 'groups.delete': {
const group = await Group.findByPk(event.modelId, {
paranoid: false,
});
socketio.to(`team-${group.teamId}`).emit('entities', {
event: event.name,
groupIds: [
{
id: group.id,
updatedAt: group.updatedAt,
},
],
});
// we the users and collection relations that were just severed as a result of the group deletion
// since there are cascading deletes, we approximate this by looking for the recently deleted
// items in the GroupUser and CollectionGroup tables
const groupUsers = await GroupUser.findAll({
paranoid: false,
where: {
groupId: event.modelId,
deletedAt: {
[Op.gt]: subHours(new Date(), 1),
},
},
});
const collectionGroupMemberships = await CollectionGroup.findAll({
paranoid: false,
where: {
groupId: event.modelId,
deletedAt: {
[Op.gt]: subHours(new Date(), 1),
},
},
});
for (const collectionGroup of collectionGroupMemberships) {
const membershipUserIds = await Collection.membershipUserIds(
collectionGroup.collectionId
);
for (const groupUser of groupUsers) {
if (membershipUserIds.includes(groupUser.userId)) {
// the user still has access through some means...
// treat this like an add, so that the client re-syncs policies
socketio
.to(`user-${groupUser.userId}`)
.emit('collections.add_user', {
event: event.name,
userId: groupUser.userId,
collectionId: collectionGroup.collectionId,
});
} else {
// let everyone with access to the collection know a user was removed
socketio
.to(`collection-${collectionGroup.collectionId}`)
.emit('collections.remove_user', {
event: event.name,
userId: groupUser.userId,
collectionId: collectionGroup.collectionId,
});
// tell any user clients to disconnect from the websocket channel for the collection
socketio.to(`user-${groupUser.userId}`).emit('leave', {
event: event.name,
collectionId: collectionGroup.collectionId,
});
}
}
}
return;
}
default: default:
} }
} }

View File

@ -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++;

View File

@ -1,3 +1,4 @@
// @flow // @flow
export const USER_PRESENCE_INTERVAL = 5000; export const USER_PRESENCE_INTERVAL = 5000;
export const MAX_AVATAR_DISPLAY = 6;

View File

@ -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`;
}

View File

@ -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"