Compare commits

...

7 Commits

11 changed files with 250 additions and 54 deletions

View File

@ -10,6 +10,18 @@ APP_NAME = os.environ.get("APP_NAME")
APP_URL = os.environ.get("APP_URL") APP_URL = os.environ.get("APP_URL")
def has_group_permission(user, group, permission=None):
if not user or not group:
return False
if user["_id"] in group.get("admins", []):
return True
if group["_id"] not in user.get("groups", []):
return False
if permission:
return permission in group.get("memberPermissions", [])
return False
def create(user, data): def create(user, data):
if not data: if not data:
raise util.errors.BadRequest("Invalid request") raise util.errors.BadRequest("Invalid request")
@ -24,6 +36,13 @@ def create(user, data):
"name": data["name"], "name": data["name"],
"description": data.get("description", ""), "description": data.get("description", ""),
"closed": data.get("closed", False), "closed": data.get("closed", False),
"memberPermissions": [
"viewMembers",
"viewNoticeboard",
"postNoticeboard",
"viewProjects",
"postProjects",
],
} }
result = db.groups.insert_one(group) result = db.groups.insert_one(group)
group["_id"] = result.inserted_id group["_id"] = result.inserted_id
@ -43,6 +62,10 @@ def get_one(user, id):
group = db.groups.find_one({"_id": id}) group = db.groups.find_one({"_id": id})
if not group: if not group:
raise util.errors.NotFound("Group not found") raise util.errors.NotFound("Group not found")
if group.get("image"):
group["imageUrl"] = uploads.get_presigned_url(
"groups/{0}/{1}".format(id, group["image"])
)
group["adminUsers"] = list( group["adminUsers"] = list(
db.users.find( db.users.find(
{"_id": {"$in": group.get("admins", [])}}, {"username": 1, "avatar": 1} {"_id": {"$in": group.get("admins", [])}}, {"username": 1, "avatar": 1}
@ -64,7 +87,7 @@ def update(user, id, update):
raise util.errors.NotFound("Group not found") raise util.errors.NotFound("Group not found")
if user["_id"] not in group.get("admins", []): if user["_id"] not in group.get("admins", []):
raise util.errors.Forbidden("You're not a group admin") raise util.errors.Forbidden("You're not a group admin")
allowed_keys = ["name", "description", "closed"] allowed_keys = ["name", "description", "closed", "memberPermissions", "image"]
updater = util.build_updater(update, allowed_keys) updater = util.build_updater(update, allowed_keys)
if updater: if updater:
db.groups.update_one({"_id": id}, updater) db.groups.update_one({"_id": id}, updater)
@ -90,11 +113,13 @@ def create_entry(user, id, data):
raise util.errors.BadRequest("Invalid request") raise util.errors.BadRequest("Invalid request")
db = database.get_db() db = database.get_db()
id = ObjectId(id) id = ObjectId(id)
group = db.groups.find_one({"_id": id}, {"admins": 1, "name": 1}) group = db.groups.find_one({"_id": id})
if not group: if not group:
raise util.errors.NotFound("Group not found") raise util.errors.NotFound("Group not found")
if group["_id"] not in user.get("groups", []): if group["_id"] not in user.get("groups", []):
raise util.errors.Forbidden("You must be a member to write in the feed") raise util.errors.Forbidden("You must be a member to write in the feed")
if not has_group_permission(user, group, "postNoticeboard"):
raise util.errors.Forbidden("You don't have permission to post in the feed")
entry = { entry = {
"createdAt": datetime.datetime.now(), "createdAt": datetime.datetime.now(),
"group": id, "group": id,
@ -158,11 +183,13 @@ def create_entry(user, id, data):
def get_entries(user, id): def get_entries(user, id):
db = database.get_db() db = database.get_db()
id = ObjectId(id) id = ObjectId(id)
group = db.groups.find_one({"_id": id}, {"admins": 1}) group = db.groups.find_one({"_id": id})
if not group: if not group:
raise util.errors.NotFound("Group not found") raise util.errors.NotFound("Group not found")
if id not in user.get("groups", []): if id not in user.get("groups", []):
raise util.errors.BadRequest("You're not a member of this group") raise util.errors.BadRequest("You're not a member of this group")
if not has_group_permission(user, group, "viewNoticeboard"):
raise util.errors.Forbidden("You don't have permission to view the feed")
entries = list( entries = list(
db.groupEntries.find({"group": id}).sort("createdAt", pymongo.DESCENDING) db.groupEntries.find({"group": id}).sort("createdAt", pymongo.DESCENDING)
) )
@ -211,7 +238,7 @@ def create_entry_reply(user, id, entry_id, data):
db = database.get_db() db = database.get_db()
id = ObjectId(id) id = ObjectId(id)
entry_id = ObjectId(entry_id) entry_id = ObjectId(entry_id)
group = db.groups.find_one({"_id": id}, {"admins": 1, "name": 1}) group = db.groups.find_one({"_id": id})
if not group: if not group:
raise util.errors.NotFound("Group not found") raise util.errors.NotFound("Group not found")
entry = db.groupEntries.find_one({"_id": entry_id}) entry = db.groupEntries.find_one({"_id": entry_id})
@ -219,6 +246,8 @@ def create_entry_reply(user, id, entry_id, data):
raise util.errors.NotFound("Entry to reply to not found") raise util.errors.NotFound("Entry to reply to not found")
if group["_id"] not in user.get("groups", []): if group["_id"] not in user.get("groups", []):
raise util.errors.Forbidden("You must be a member to write in the feed") raise util.errors.Forbidden("You must be a member to write in the feed")
if not has_group_permission(user, group, "postNoticeboard"):
raise util.errors.Forbidden("You don't have permission to post in the feed")
reply = { reply = {
"createdAt": datetime.datetime.now(), "createdAt": datetime.datetime.now(),
"group": id, "group": id,
@ -346,11 +375,13 @@ def create_member(user, id, user_id, invited=False):
def get_members(user, id): def get_members(user, id):
db = database.get_db() db = database.get_db()
id = ObjectId(id) id = ObjectId(id)
group = db.groups.find_one({"_id": id}, {"admins": 1}) group = db.groups.find_one({"_id": id})
if not group: if not group:
raise util.errors.NotFound("Group not found") raise util.errors.NotFound("Group not found")
if id not in user.get("groups", []) and "root" not in user.get("roles", []): if id not in user.get("groups", []) and "root" not in user.get("roles", []):
raise util.errors.Forbidden("You need to be a member to see the member list") raise util.errors.Forbidden("You need to be a member to see the member list")
if not has_group_permission(user, group, "viewMembers"):
raise util.errors.Forbidden("You don't have permission to view the member list")
members = list( members = list(
db.users.find( db.users.find(
{"groups": id}, {"username": 1, "avatar": 1, "bio": 1, "groups": 1} {"groups": id}, {"username": 1, "avatar": 1, "bio": 1, "groups": 1}
@ -385,14 +416,52 @@ def delete_member(user, id, user_id):
return {"deletedMember": user_id} return {"deletedMember": user_id}
def create_admin(user, id, user_id):
id = ObjectId(id)
user_id = ObjectId(user_id)
db = database.get_db()
group = db.groups.find_one({"_id": id}, {"admins": 1})
if not group:
raise util.errors.NotFound("Group not found")
if user["_id"] not in group.get("admins", []):
raise util.errors.Forbidden("You can't add this admin")
if user_id in group.get("admins", []):
raise util.errors.Forbidden("This user is already an admin")
db.groups.update_one({"_id": id}, {"$addToSet": {"admins": user_id}})
return {"createdAdmin": user_id}
def delete_admin(user, id, user_id):
id = ObjectId(id)
user_id = ObjectId(user_id)
db = database.get_db()
group = db.groups.find_one({"_id": id}, {"admins": 1})
if not group:
raise util.errors.NotFound("Group not found")
if user_id != user["_id"] and user["_id"] not in group.get("admins", []):
raise util.errors.Forbidden("You can't remove this admin")
if user_id not in group.get("admins", []):
raise util.errors.Forbidden("This user is not an admin")
if len(group["admins"]) == 1:
raise util.errors.Forbidden(
"There needs to be at least one admin in this group"
)
db.groups.update_one({"_id": id}, {"$pull": {"admins": user_id}})
return {"deletedAdmin": user_id}
def get_projects(user, id): def get_projects(user, id):
db = database.get_db() db = database.get_db()
id = ObjectId(id) id = ObjectId(id)
group = db.groups.find_one({"_id": id}, {"admins": 1}) group = db.groups.find_one({"_id": id})
if not group: if not group:
raise util.errors.NotFound("Group not found") raise util.errors.NotFound("Group not found")
if id not in user.get("groups", []): if id not in user.get("groups", []):
raise util.errors.Forbidden("You need to be a member to see the project list") raise util.errors.Forbidden("You need to be a member to see the project list")
if not has_group_permission(user, group, "viewProjects"):
raise util.errors.Forbidden(
"You don't have permission to view the project list"
)
projects = list( projects = list(
db.projects.find( db.projects.find(
{"groupVisibility": id}, {"groupVisibility": id},

View File

@ -1,4 +1,5 @@
import datetime import datetime
import re
from bson.objectid import ObjectId from bson.objectid import ObjectId
from util import database, util from util import database, util
from api import uploads from api import uploads
@ -99,6 +100,10 @@ def update(user, username, data):
if "username" in data: if "username" in data:
if not data.get("username") or len(data["username"]) < 3: if not data.get("username") or len(data["username"]) < 3:
raise util.errors.BadRequest("New username is not valid") raise util.errors.BadRequest("New username is not valid")
if not re.match("^[a-z0-9_]+$", data["username"]):
raise util.errors.BadRequest(
"Usernames can only contain letters, numbers, and underscores"
)
if db.users.count_documents({"username": data["username"].lower()}): if db.users.count_documents({"username": data["username"].lower()}):
raise util.errors.BadRequest("A user with this username already exists") raise util.errors.BadRequest("A user with this username already exists")
data["username"] = data["username"].lower() data["username"] = data["username"].lower()

View File

@ -478,6 +478,8 @@ def group_route(id):
"name": fields.Str(), "name": fields.Str(),
"description": fields.Str(), "description": fields.Str(),
"closed": fields.Bool(), "closed": fields.Bool(),
"memberPermissions": fields.List(fields.Str()),
"image": fields.Str(allow_none=True),
} }
) )
def group_route_put(args, id): def group_route_put(args, id):
@ -542,6 +544,18 @@ def group_member_route(id, user_id):
) )
@app.route("/groups/<id>/admins/<user_id>", methods=["PUT", "DELETE"])
def group_admin_route(id, user_id):
if request.method == "DELETE":
return util.jsonify(
groups.delete_admin(util.get_user(required=True), id, user_id)
)
if request.method == "PUT":
return util.jsonify(
groups.create_admin(util.get_user(required=True), id, user_id)
)
@app.route("/groups/<id>/projects", methods=["GET"]) @app.route("/groups/<id>/projects", methods=["GET"])
def group_projects_route(id): def group_projects_route(id):
return util.jsonify(groups.get_projects(util.get_user(required=True), id)) return util.jsonify(groups.get_projects(util.get_user(required=True), id))

View File

@ -57,5 +57,11 @@ export const groups = {
}, },
deleteInvitation (id, inviteId, success, fail) { deleteInvitation (id, inviteId, success, fail) {
api.authenticatedRequest('DELETE', `/groups/${id}/invitations/${inviteId}`, null, success, fail) api.authenticatedRequest('DELETE', `/groups/${id}/invitations/${inviteId}`, null, success, fail)
},
createAdmin (id, userId, success, fail) {
api.authenticatedRequest('PUT', `/groups/${id}/admins/${userId}`, null, success, fail)
},
deleteAdmin (id, userId, success, fail) {
api.authenticatedRequest('DELETE', `/groups/${id}/admins/${userId}`, null, success, fail)
} }
} }

View File

@ -84,7 +84,7 @@ const FeedMessage = connect(
</div>} </div>}
{!post.inReplyTo && {!post.inReplyTo &&
<div style={{ padding: 10 }}> <div style={{ padding: 10 }}>
{replyingTo !== post._id && !post.inReplyTo && onReplyPosted && {utils.hasGroupPermission(user, group, 'postNoticeboard') && replyingTo !== post._id && !post.inReplyTo && onReplyPosted &&
<Button size='mini' basic primary icon='reply' content='Write a reply' onClick={() => updateReplyingTo(post._id)} />} <Button size='mini' basic primary icon='reply' content='Write a reply' onClick={() => updateReplyingTo(post._id)} />}
{post.user === user?._id && !utils.hasSubscription(user, 'messages.replied') && onReplyPosted && {post.user === user?._id && !utils.hasSubscription(user, 'messages.replied') && onReplyPosted &&
<Button size='mini' basic icon='envelope' content='Get notified if someone replies' as={Link} to='/settings/notifications' />} <Button size='mini' basic icon='envelope' content='Get notified if someone replies' as={Link} to='/settings/notifications' />}

View File

@ -29,8 +29,8 @@ const Badge = styled.div`
left: -8px; left: -8px;
` `
const Username = styled.span` const Username = styled.span`
font-size: ${props => props.compact ? 10 : 12}px; font-size: ${props => props.compact ? 12 : 14}px;
margin-left: 5px; margin-left: 3px;
color: black; color: black;
` `

View File

@ -2,6 +2,7 @@ import React, { useEffect } from 'react'
import { Loader, Button, Segment } from 'semantic-ui-react' import { Loader, Button, Segment } from 'semantic-ui-react'
import { useSelector, useDispatch } from 'react-redux' import { useSelector, useDispatch } from 'react-redux'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { toast } from 'react-toastify'
import utils from '../../../utils/utils.js' import utils from '../../../utils/utils.js'
import actions from '../../../actions' import actions from '../../../actions'
import api from '../../../api' import api from '../../../api'
@ -34,26 +35,42 @@ function Feed () {
}) })
}, [dispatch, id, myGroups.length]) }, [dispatch, id, myGroups.length])
const toggleEmailSub = (key, enable) => {
if (enable) { api.users.createEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)) } else { api.users.deleteEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)) }
}
const mainEntries = entries && entries.filter(e => !e.inReplyTo) const mainEntries = entries && entries.filter(e => !e.inReplyTo)
return ( return (
<div> <div>
{utils.isInGroup(user, group._id) && {utils.hasGroupPermission(user, group, 'viewNoticeboard') &&
<> <>
{replyingTo <div style={{ display: 'flex', justifyContent: 'end', marginBottom: 10 }}>
? <Button style={{ marginBottom: 20 }} color='teal' content='Write a new post' onClick={() => dispatch(actions.posts.updateReplyingTo(null))} /> {utils.hasEmailSubscription(user, `groupFeed-${group._id}`)
: <NewFeedMessage user={user} group={group} forType='group' onPosted={e => dispatch(actions.groups.receiveEntry(e))} />} ? <Button color='blue' basic size='tiny' icon='check' content='Subscribed to email updates' onClick={e => toggleEmailSub(`groupFeed-${group._id}`, false)} />
: <Button color='blue' size='tiny' icon='rss' content='Subscribe to updates' onClick={e => toggleEmailSub(`groupFeed-${group._id}`, true)} />}
</div>
{utils.hasGroupPermission(user, group, 'postNoticeboard') &&
<>
{replyingTo
? <Button style={{ marginBottom: 20 }} color='teal' content='Write a new post' onClick={() => dispatch(actions.posts.updateReplyingTo(null))} />
: <NewFeedMessage user={user} group={group} forType='group' onPosted={e => dispatch(actions.groups.receiveEntry(e))} />}
</>}
{loadingEntries && !mainEntries?.length && {loadingEntries && !mainEntries?.length &&
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Loader inline='centered' active /> <Loader inline='centered' active />
<p style={{ marginTop: 20 }}><strong>Loading the notice board. Hold tight...</strong></p> <p style={{ marginTop: 20 }}><strong>Loading the notice board. Hold tight...</strong></p>
</div>} </div>}
{!loadingEntries && !mainEntries?.length && {!loadingEntries && !mainEntries?.length &&
<Segment placeholder textAlign='center'> <Segment placeholder textAlign='center'>
<img src={MessagesImage} alt='Messages' style={{ display: 'block', margin: '0px auto', maxWidth: 300 }} /> <img src={MessagesImage} alt='Messages' style={{ display: 'block', margin: '0px auto', maxWidth: 300 }} />
<h2>No posts yet</h2> <h2>No posts yet</h2>
<p>Be the first here by writing a new post.</p> <p>Be the first here by writing a new post.</p>
</Segment>} </Segment>}
{mainEntries?.map(e => {mainEntries?.map(e =>
<FeedMessage key={e._id} user={user} forType='group' group={group} post={e} replies={entries.filter(r => r.inReplyTo === e._id)} onDeleted={id => dispatch(actions.groups.deleteEntry(id))} onReplyPosted={e => dispatch(actions.groups.receiveEntry(e))} /> <FeedMessage key={e._id} user={user} forType='group' group={group} post={e} replies={entries.filter(r => r.inReplyTo === e._id)} onDeleted={id => dispatch(actions.groups.deleteEntry(id))} onReplyPosted={e => dispatch(actions.groups.receiveEntry(e))} />
)} )}

View File

@ -1,6 +1,6 @@
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { Helmet } from 'react-helmet' import { Helmet } from 'react-helmet'
import { Segment, Loader, Menu, Message, Container, Button, Icon, Grid, Card } from 'semantic-ui-react' import { Segment, Loader, Menu, Message, Container, Button, Icon, Grid, Card, Image } from 'semantic-ui-react'
import { Outlet, Link, useParams } from 'react-router-dom' import { Outlet, Link, useParams } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
@ -15,10 +15,7 @@ function Group () {
const { id } = useParams() const { id } = useParams()
const dispatch = useDispatch() const dispatch = useDispatch()
const { user, group, loading, errorMessage, requests, myRequests, invitations } = useSelector(state => { const { user, group, loading, errorMessage, requests, myRequests, invitations } = useSelector(state => {
let group const group = state.groups.groups.filter(g => g._id === id)[0]
state.groups.groups.forEach((g) => {
if (g._id === id) group = g
})
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0] const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]
const requests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id) const requests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id)
const myRequests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id && i.user === user?._id) const myRequests = state.invitations.invitations.filter(i => i.recipientGroup === group?._id && i.user === user?._id)
@ -44,9 +41,6 @@ function Group () {
}, err => toast.error(err.message)) }, err => toast.error(err.message))
}, () => {}) }, () => {})
} }
const toggleEmailSub = (key, enable) => {
if (enable) { api.users.createEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)) } else { api.users.deleteEmailSubscription(user.username, key, ({ subscriptions }) => dispatch(actions.users.updateSubscriptions(user._id, subscriptions)), err => toast.error(err.message)) }
}
const requestToJoin = () => { const requestToJoin = () => {
api.groups.createJoinRequest(group._id, invitation => { api.groups.createJoinRequest(group._id, invitation => {
@ -89,52 +83,55 @@ function Group () {
<Grid stackable> <Grid stackable>
<Grid.Column computer={4}> <Grid.Column computer={4}>
<Card fluid color='yellow'> <Card fluid color='yellow'>
{group.imageUrl &&
<Image src={group.imageUrl} wrapped ui={false} />}
{group.description && {group.description &&
<Card.Content>{group.description}</Card.Content>} <Card.Content>{group.description}</Card.Content>}
{group.closed && {group.closed &&
<Card.Content> <Card.Content>
{utils.isInGroup(user, group._id) &&
<div>
<Button color='yellow' basic size='tiny' fluid icon='check' content='Member' onClick={leave} style={{ marginBottom: 5 }} />
</div>}
{!utils.isInGroup(user, group._id) &&
(group.closed
? <Button disabled={myRequests?.length > 0} color='yellow' size='tiny' fluid icon='user plus' content='Request to join' onClick={requestToJoin} />
: <Button color='yellow' size='tiny' fluid icon='user plus' content='Join group' onClick={join} />
)}
<Card.Meta> <Card.Meta>
<h4 style={{ marginBottom: 2 }}><Icon name='user secret' /> This is a closed group</h4>
<p>Members can only join if they are approved or invited by an admin.</p>
{requests?.length > 0 && utils.isGroupAdmin(user, group) && {requests?.length > 0 && utils.isGroupAdmin(user, group) &&
<Button as={Link} to={`/groups/${group._id}/members`} size='tiny' fluid color='teal' icon='user plus' content='Manage join requests' />} <Button as={Link} to={`/groups/${group._id}/members`} size='tiny' fluid color='teal' icon='user plus' content='Manage join requests' />}
{utils.isGroupAdmin(user, group) && !utils.hasSubscription(user, 'groups.joinRequested') && {utils.isGroupAdmin(user, group) && !utils.hasSubscription(user, 'groups.joinRequested') &&
<Button as={Link} to='/settings/notifications' icon='envelope' size='tiny' fluid basic content='Email me join requests' />} <Button as={Link} to='/settings/notifications' icon='envelope' size='tiny' fluid basic content='Email me join requests' />}
</Card.Meta> </Card.Meta>
</Card.Content>} </Card.Content>}
<Card.Content>
<h3>Admins</h3>
{group.adminUsers && group.adminUsers.map(a =>
<UserChip user={a} key={a._id} />
)}
</Card.Content>
<Card.Content extra>
{utils.isInGroup(user, group._id) &&
<div>
<Button color='yellow' basic size='tiny' fluid icon='check' content='Member' onClick={leave} style={{ marginBottom: 5 }} />
{utils.hasEmailSubscription(user, `groupFeed-${group._id}`)
? <Button color='blue' basic size='tiny' fluid icon='rss' content='Subscribed' onClick={e => toggleEmailSub(`groupFeed-${group._id}`, false)} data-tooltip="We'll send you emails when people post in this group" />
: <Button color='blue' size='tiny' fluid icon='rss' content='Subscribe to updates' onClick={e => toggleEmailSub(`groupFeed-${group._id}`, true)} />}
</div>}
{!utils.isInGroup(user, group._id) &&
(group.closed
? <Button disabled={myRequests?.length > 0} color='yellow' size='tiny' fluid icon='user plus' content='Request to join' onClick={requestToJoin} />
: <Button color='yellow' size='tiny' fluid icon='user plus' content='Join group' onClick={join} />
)}
</Card.Content>
</Card> </Card>
{utils.isInGroup(user, group._id) && {utils.isInGroup(user, group._id) &&
<Menu fluid vertical> <Menu fluid vertical>
<Menu.Item active={utils.activePath('^/groups/[a-zA-Z0-9]+$')} as={Link} to={`/groups/${group._id}`} icon='chat' content='Notice Board' /> <Menu.Item active={utils.activePath('^/groups/[a-zA-Z0-9]+$')} as={Link} to={`/groups/${group._id}`} icon='chat' content='Notice Board' />
{utils.isInGroup(user, group._id) && {utils.isInGroup(user, group._id) &&
<Menu.Item active={utils.activePath('members')} as={Link} to={`/groups/${group._id}/members`} icon='user' content='Members' label='3' />} <>
{utils.isInGroup(user, group._id) && {utils.hasGroupPermission(user, group, 'viewMembers') &&
<Menu.Item active={utils.activePath('projects')} as={Link} to={`/groups/${group._id}/projects`} icon='book' content='Projects' />} <Menu.Item active={utils.activePath('members')} as={Link} to={`/groups/${group._id}/members`} icon='user' content='Members' />}
{utils.isGroupAdmin(user, group) && {utils.hasGroupPermission(user, group, 'viewProjects') &&
<Menu.Item active={utils.activePath('settings')} as={Link} to={`/groups/${group._id}/settings`} icon='settings' content='Settings' />} <Menu.Item active={utils.activePath('projects')} as={Link} to={`/groups/${group._id}/projects`} icon='book' content='Projects' />}
{utils.isGroupAdmin(user, group) &&
<Menu.Item active={utils.activePath('settings')} as={Link} to={`/groups/${group._id}/settings`} icon='settings' content='Settings' />}
</>}
</Menu>} </Menu>}
<Card fluid color='yellow'>
<Card.Content>
<h3>Admins</h3>
<div style={{ flexWrap: 'wrap', display: 'flex' }}>
{group?.adminUsers?.map(a =>
<div style={{ marginRight: 8, marginBottom: 4 }} key={a._id}><UserChip user={a} /></div>
)}
</div>
</Card.Content>
</Card>
<HelpLink link='/docs/groups#a-tour-around-your-new-group' /> <HelpLink link='/docs/groups#a-tour-around-your-new-group' />
</Grid.Column> </Grid.Column>

View File

@ -14,6 +14,7 @@ import UserSearch from '../../includes/UserSearch'
function Members () { function Members () {
const [invitations, setInvitations] = useState([]) const [invitations, setInvitations] = useState([])
const [search, setSearch] = useState('')
const joinLinkRef = useRef(null) const joinLinkRef = useRef(null)
const { id } = useParams() const { id } = useParams()
const dispatch = useDispatch() const dispatch = useDispatch()
@ -54,6 +55,19 @@ function Members () {
}, err => toast.error(err.message)) }, err => toast.error(err.message))
}, () => {}) }, () => {})
} }
const makeUserAdmin = (member, promoting) => {
if (promoting) {
api.groups.createAdmin(group._id, member._id, () => {
toast.success('User is now an admin')
dispatch(actions.groups.updateGroup(group._id, { admins: group.admins.concat(member._id), adminUsers: group.adminUsers.concat(member) }))
}, err => toast.error(err.message))
} else {
api.groups.deleteAdmin(group._id, member._id, () => {
toast.success('Admin status revoked')
dispatch(actions.groups.updateGroup(group._id, { admins: group.admins.filter(a => a !== member._id), adminUsers: group.adminUsers.filter(a => a._id !== member._id) }))
}, err => toast.error(err.message))
}
}
const sendInvitation = (user) => { const sendInvitation = (user) => {
api.groups.createInvitation(group._id, user._id, invitation => { api.groups.createInvitation(group._id, user._id, invitation => {
const newInvitations = Object.assign([], invitations) const newInvitations = Object.assign([], invitations)
@ -81,6 +95,9 @@ function Members () {
err => toast.error(err.message)) err => toast.error(err.message))
} }
const memberList = search ? members.filter(m => m?.username?.toLowerCase().includes(search.trim().toLowerCase())) : members
const invitationList = search ? invitations.filter(i => i?.recipientUser?.username?.toLowerCase().includes(search.trim().toLowerCase())) : invitations
return ( return (
<div> <div>
{loading && (!members || !members.length) && <Loader active inline='centered' />} {loading && (!members || !members.length) && <Loader active inline='centered' />}
@ -126,9 +143,11 @@ function Members () {
</Table> </Table>
</Segment>} </Segment>}
<Input style={{ marginBottom: 20 }} placeholder='Search by username...' value={search} onChange={e => setSearch(e.target.value)} />
<Card.Group itemsPerRow={3} doubling stackable> <Card.Group itemsPerRow={3} doubling stackable>
{invitations && invitations.map(i => {invitationList?.map(i =>
<Card key={i._id}> <Card key={i._id} style={{ opacity: 0.6 }}>
<Card.Content> <Card.Content>
<UserChip user={i.recipientUser} /> <UserChip user={i.recipientUser} />
</Card.Content> </Card.Content>
@ -145,7 +164,7 @@ function Members () {
</Card.Content> </Card.Content>
</Card> </Card>
)} )}
{members && members.map(m => {memberList?.map(m =>
<Card key={m._id}> <Card key={m._id}>
<Card.Content> <Card.Content>
<UserChip user={m} /> <UserChip user={m} />
@ -158,7 +177,11 @@ function Members () {
{utils.isGroupAdmin(user, group) && user._id !== m._id && {utils.isGroupAdmin(user, group) && user._id !== m._id &&
<Dropdown text='Options'> <Dropdown text='Options'>
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Item icon='ban' content='Kick' onClick={e => kickUser(m._id)} /> <Dropdown.Item icon='ban' content='Remove from group' onClick={e => kickUser(m._id)} />
{!utils.isGroupAdmin(m, group) &&
<Dropdown.Item icon='rocket' content='Make admin' onClick={e => makeUserAdmin(m, true)} />}
{utils.isGroupAdmin(m, group) &&
<Dropdown.Item icon='ban' content='Revoke admin' onClick={e => makeUserAdmin(m, false)} />}
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown>} </Dropdown>}
</div> </div>

View File

@ -1,11 +1,19 @@
import React from 'react' import React from 'react'
import { Header, Button, Divider, Segment, Form } from 'semantic-ui-react' import { Header, Button, Divider, Segment, Form, Checkbox } from 'semantic-ui-react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams } from 'react-router-dom'
import { useSelector, useDispatch } from 'react-redux' import { useSelector, useDispatch } from 'react-redux'
import { toast } from 'react-toastify' import { toast } from 'react-toastify'
import utils from '../../../utils/utils.js' import utils from '../../../utils/utils.js'
import actions from '../../../actions' import actions from '../../../actions'
import api from '../../../api' import api from '../../../api'
import FileChooser from '../../includes/FileChooser'
const PERMISSIONS = [
{ name: 'viewMembers', label: 'Allow members to view other members' },
{ name: 'viewNoticeboard', label: 'Allow members to view the noticeboard' },
{ name: 'postNoticeboard', label: 'Allow members to post to the noticeboard' },
{ name: 'viewProjects', label: 'Allow members to view projects linked to the group' }
]
function Settings () { function Settings () {
const navigate = useNavigate() const navigate = useNavigate()
@ -29,6 +37,22 @@ function Settings () {
dispatch(actions.groups.request(false)) dispatch(actions.groups.request(false))
}) })
} }
const savePermission = (permissionName, enabled) => {
const permissions = group.memberPermissions || []
const index = permissions.indexOf(permissionName)
if (enabled && index === -1) {
permissions.push(permissionName)
} else if (!enabled && index !== -1) {
permissions.splice(index, 1)
}
api.groups.update(group._id, { memberPermissions: permissions }, g => {
dispatch(actions.groups.updateGroup(group._id, { memberPermissions: g.memberPermissions }))
}, err => {
toast.error(err.message)
})
}
const deleteGroup = () => { const deleteGroup = () => {
utils.confirm('Really delete this group?', 'You\'ll lose all entries in the group feed and anything else you\'ve added to it.').then(() => { utils.confirm('Really delete this group?', 'You\'ll lose all entries in the group feed and anything else you\'ve added to it.').then(() => {
api.groups.delete(group._id, () => { api.groups.delete(group._id, () => {
@ -39,6 +63,16 @@ function Settings () {
}, () => {}) }, () => {})
} }
const updatePicture = (image) => {
api.groups.update(group._id, { image }, g => {
if (!image) { // Needed to ensure the avatar is immediately unset
g.image = null
g.imageUrl = null
}
dispatch(actions.groups.updateGroup(group._id, { image: g.image, imageUrl: g.imageUrl }))
})
}
return ( return (
<div> <div>
<Segment color='blue'> <Segment color='blue'>
@ -48,12 +82,40 @@ function Settings () {
<Form.TextArea label='Group description' value={group.description} onChange={e => dispatch(actions.groups.updateGroup(group._id, { description: e.target.value }))} /> <Form.TextArea label='Group description' value={group.description} onChange={e => dispatch(actions.groups.updateGroup(group._id, { description: e.target.value }))} />
<Form.Checkbox toggle checked={group.closed} label='Closed group' onChange={(e, c) => dispatch(actions.groups.updateGroup(group._id, { closed: c.checked }))} /> <Form.Checkbox toggle checked={group.closed} label='Closed group' onChange={(e, c) => dispatch(actions.groups.updateGroup(group._id, { closed: c.checked }))} />
<div style={{ marginLeft: 63, color: 'rgb(150,150,150)' }}> <div style={{ marginLeft: 63, color: 'rgb(150,150,150)' }}>
<p>Closed groups are more restrictive and new members must be invited or approved to join. Members can join non-closed groups without being invited.</p> <p>In a closed group, new members must be invited or approved to join.</p>
</div> </div>
<Divider hidden /> <Divider hidden />
<Form.Button loading={loading} color='teal' icon='check' content='Save changes' onClick={saveGroup} /> <Form.Button loading={loading} color='teal' icon='check' content='Save changes' onClick={saveGroup} />
</Form> </Form>
<Header>Group picture</Header>
<p>Upload a picture to represent your group. This will be shown on the group page and may be viewable to both members and non-members.</p>
{group.imageUrl &&
<div style={{ marginBottom: 10 }}>
<img src={group.imageUrl} alt='Group' style={{ maxWidth: 200, borderRadius: 5 }} />
</div>}
<div style={{ display: 'flex', alignItems: 'center' }}>
{group.imageUrl &&
<Button basic color='gray' icon='times' content='Remove image' onClick={() => updatePicture(null)} />}
<FileChooser
forType='group' forObject={group}
trigger={<Button basic color='yellow' icon='image' content='Choose an image' />}
accept='image/*' onComplete={f => updatePicture(f.storedName)}
/>
</div>
</Segment>
<Segment color='blue'>
<Header>Permissions</Header>
<p>Group admins can always perform all group actions.</p>
{PERMISSIONS.map(p => (
<div key={p.name} style={{ marginBottom: 10 }}>
<Checkbox toggle label={p.label} onChange={(e, c) => savePermission(p.name, c.checked)} checked={group.memberPermissions?.includes(p.name)} />
</div>
))}
</Segment> </Segment>
<Segment color='red'> <Segment color='red'>

View File

@ -35,6 +35,9 @@ const utils = {
isGroupAdmin (user, group) { isGroupAdmin (user, group) {
return group?.admins?.indexOf(user?._id) > -1 || user?.roles?.indexOf('root') > -1 return group?.admins?.indexOf(user?._id) > -1 || user?.roles?.indexOf('root') > -1
}, },
hasGroupPermission (user, group, permission) {
return utils.isInGroup(user, group._id) && (utils.isGroupAdmin(user, group) || group?.memberPermissions?.indexOf(permission) > -1)
},
ensureHttp (s) { ensureHttp (s) {
if (s && s.toLowerCase().indexOf('http') === -1) return `http://${s}` if (s && s.toLowerCase().indexOf('http') === -1) return `http://${s}`
return s return s