Compare commits
7 Commits
9cd1ae4628
...
81bed97d42
Author | SHA1 | Date | |
---|---|---|---|
81bed97d42 | |||
402a25d980 | |||
f021914089 | |||
a8a000ae55 | |||
980a5bb14b | |||
8afd7c5694 | |||
17806d410b |
@ -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},
|
||||||
|
@ -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()
|
||||||
|
14
api/app.py
14
api/app.py
@ -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))
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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' />}
|
||||||
|
@ -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;
|
||||||
`
|
`
|
||||||
|
|
||||||
|
@ -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))} />
|
||||||
)}
|
)}
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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'>
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user