Allow users to leave groups if not owner

This commit is contained in:
2021-11-24 19:05:50 +00:00
parent a15032f2a1
commit 94e4bd9d43
3 changed files with 656 additions and 580 deletions

View File

@@ -94,7 +94,8 @@ service cloud.firestore {
return [requiredFields, allFields]; return [requiredFields, allFields];
} }
allow read, delete: if isSignedIn() && (isSignedInUser() || getGroupRole(groupId) == "owner" || isAdmin()); // is current user's data or is owner of group or is admin allow read: if isSignedIn() && (isSignedInUser() || getGroupRole(groupId) == "owner" || isAdmin()); // is current user's data or is owner of group or is admin
allow delete: if isSignedIn() && ((isSignedInUser() && getGroupRole(groupId) != "owner") || (!isSignedInUser() && getGroupRole(groupId) == "owner") || isAdmin())
allow create: if isSignedIn() && isSignedInUser() && (getRequestField("role", "") == "member" || (isAdmin() && verifyGroupFieldTypes())) && verifyCreateFields(getPossibleGroupFields()); allow create: if isSignedIn() && isSignedInUser() && (getRequestField("role", "") == "member" || (isAdmin() && verifyGroupFieldTypes())) && verifyCreateFields(getPossibleGroupFields());
allow update: if isSignedIn() && allow update: if isSignedIn() &&
(getGroupRole(groupId) == "owner" || isAdmin()) && (getGroupRole(groupId) == "owner" || isAdmin()) &&

View File

@@ -19,7 +19,8 @@ import "./css/GroupPage.css";
import "./css/ConfirmationDialog.css"; import "./css/ConfirmationDialog.css";
import "./css/OptionsListOverlay.css"; import "./css/OptionsListOverlay.css";
export default withRouter(class GroupPage extends Component { export default withRouter(
class GroupPage extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {
@@ -35,7 +36,7 @@ export default withRouter(class GroupPage extends Component {
link: "/", link: "/",
icon: <HomeRoundedIcon />, icon: <HomeRoundedIcon />,
hideTextMobile: true, hideTextMobile: true,
} },
], ],
role: null, role: null,
groupName: "", groupName: "",
@@ -52,18 +53,20 @@ export default withRouter(class GroupPage extends Component {
editingUser: null, editingUser: null,
showDeleteGroup: false, showDeleteGroup: false,
deleteGroupLoading: false, deleteGroupLoading: false,
showLeaveGroup: false,
leaveGroupLoading: false,
}; };
let isMounted = true; let isMounted = true;
Object.defineProperty(this, "isMounted", { Object.defineProperty(this, "isMounted", {
get: () => isMounted, get: () => isMounted,
set: (value) => isMounted = value, set: (value) => (isMounted = value),
}); });
} }
setState = (state, callback = null) => { setState = (state, callback = null) => {
if (this.isMounted) super.setState(state, callback); if (this.isMounted) super.setState(state, callback);
} };
async componentDidMount() { async componentDidMount() {
let promises = []; let promises = [];
@@ -92,7 +95,7 @@ export default withRouter(class GroupPage extends Component {
{ {
displayName: this.state.user.displayName, displayName: this.state.user.displayName,
uid: this.state.user.uid, uid: this.state.user.uid,
} },
], ],
contributors: [], contributors: [],
members: [], members: [],
@@ -116,8 +119,10 @@ export default withRouter(class GroupPage extends Component {
.doc(this.props.match.params.groupId) .doc(this.props.match.params.groupId)
.get() .get()
.then(async (groupDoc) => { .then(async (groupDoc) => {
await Promise.all(groupDoc.data().sets.map((setId) => { await Promise.all(
return this.state.db.collection("sets") groupDoc.data().sets.map((setId) => {
return this.state.db
.collection("sets")
.doc(setId) .doc(setId)
.get() .get()
.then((doc) => { .then((doc) => {
@@ -126,10 +131,12 @@ export default withRouter(class GroupPage extends Component {
loading: false, loading: false,
}; };
}); });
})); })
);
return groupDoc.data(); return groupDoc.data();
}).catch((error) => { })
.catch((error) => {
console.log(`Can't access group: ${error}`); console.log(`Can't access group: ${error}`);
return { return {
display_name: "", display_name: "",
@@ -146,7 +153,9 @@ export default withRouter(class GroupPage extends Component {
newState.role = completedPromises[0].role; newState.role = completedPromises[0].role;
newState.groupName = completedPromises[1].display_name; newState.groupName = completedPromises[1].display_name;
newState.originalGroupName = completedPromises[1].display_name; newState.originalGroupName = completedPromises[1].display_name;
newState.memberCount = Object.keys(completedPromises[1].users).length + (Object.keys(completedPromises[1].users).includes(this.state.user.uid) ? 0 : 1); newState.memberCount =
Object.keys(completedPromises[1].users).length +
(Object.keys(completedPromises[1].users).includes(this.state.user.uid) ? 0 : 1);
newState.joinCode = completedPromises[0].role === "owner" ? completedPromises[1].join_code : ""; newState.joinCode = completedPromises[0].role === "owner" ? completedPromises[1].join_code : "";
this.setState(newState); this.setState(newState);
@@ -164,16 +173,19 @@ export default withRouter(class GroupPage extends Component {
} }
editGroupName = () => { editGroupName = () => {
this.setState({ this.setState(
{
editGroupName: true, editGroupName: true,
}, () => this.groupNameInput.focus()); },
} () => this.groupNameInput.focus()
);
};
handleGroupNameChange = (event) => { handleGroupNameChange = (event) => {
this.setState({ this.setState({
groupName: event.target.value, groupName: event.target.value,
}); });
} };
handleInputKeypress = (event) => { handleInputKeypress = (event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
@@ -181,21 +193,21 @@ export default withRouter(class GroupPage extends Component {
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
this.cancelGroupRename(); this.cancelGroupRename();
} }
} };
stopLoading = () => { stopLoading = () => {
this.setState({ this.setState({
loading: false, loading: false,
editGroupName: false, editGroupName: false,
}) });
} };
cancelGroupRename = () => { cancelGroupRename = () => {
this.setState({ this.setState({
editGroupName: false, editGroupName: false,
groupName: this.state.originalGroupName, groupName: this.state.originalGroupName,
}) });
} };
renameGroup = () => { renameGroup = () => {
if (!this.state.loading && this.state.groupName.replace(" ", "") !== "") { if (!this.state.loading && this.state.groupName.replace(" ", "") !== "") {
@@ -206,13 +218,16 @@ export default withRouter(class GroupPage extends Component {
loading: true, loading: true,
}); });
this.state.db.collection("groups") this.state.db
.collection("groups")
.doc(this.props.match.params.groupId) .doc(this.props.match.params.groupId)
.update({ .update({
display_name: this.state.groupName.trim(), display_name: this.state.groupName.trim(),
}).then(() => { })
.then(() => {
this.stopLoading(); this.stopLoading();
}).catch((error) => { })
.catch((error) => {
console.log(`Couldn't update group name: ${error}`); console.log(`Couldn't update group name: ${error}`);
this.setState({ this.setState({
loading: false, loading: false,
@@ -222,7 +237,7 @@ export default withRouter(class GroupPage extends Component {
}); });
} }
} }
} };
removeSet = (setId) => { removeSet = (setId) => {
let newLoadingState = { let newLoadingState = {
@@ -231,21 +246,24 @@ export default withRouter(class GroupPage extends Component {
newLoadingState.sets[setId].loading = true; newLoadingState.sets[setId].loading = true;
this.setState(newLoadingState); this.setState(newLoadingState);
this.state.functions.removeSetFromGroup({ this.state.functions
.removeSetFromGroup({
groupId: this.props.match.params.groupId, groupId: this.props.match.params.groupId,
setId: setId, setId: setId,
}).then(() => { })
.then(() => {
let newState = { let newState = {
sets: this.state.sets, sets: this.state.sets,
}; };
delete newState.sets[setId]; delete newState.sets[setId];
this.setState(newState); this.setState(newState);
}).catch((error) => { })
.catch((error) => {
console.log(`Can't remove set from group: ${error}`); console.log(`Can't remove set from group: ${error}`);
newLoadingState.sets[setId].loading = false; newLoadingState.sets[setId].loading = false;
this.setState(newLoadingState); this.setState(newLoadingState);
}); });
} };
showEditUserRole = (role, index) => { showEditUserRole = (role, index) => {
let user; let user;
@@ -263,13 +281,13 @@ export default withRouter(class GroupPage extends Component {
index: index, index: index,
}, },
}); });
} };
hideEditUserRole = () => { hideEditUserRole = () => {
this.setState({ this.setState({
editingUser: null, editingUser: null,
}); });
} };
editUserRole = (role) => { editUserRole = (role) => {
if (role === this.state.editingUser.role) { if (role === this.state.editingUser.role) {
@@ -278,7 +296,8 @@ export default withRouter(class GroupPage extends Component {
}); });
} else { } else {
if (role === "remove") { if (role === "remove") {
this.state.db.collection("users") this.state.db
.collection("users")
.doc(this.state.editingUser.uid) .doc(this.state.editingUser.uid)
.collection("groups") .collection("groups")
.doc(this.props.match.params.groupId) .doc(this.props.match.params.groupId)
@@ -296,20 +315,23 @@ export default withRouter(class GroupPage extends Component {
editingUser: null, editingUser: null,
groupUsers: groupUsers, groupUsers: groupUsers,
}); });
}).catch((error) => { })
.catch((error) => {
this.setState({ this.setState({
editingUser: null, editingUser: null,
}); });
console.log(`Couldn't change user role: ${error}`) console.log(`Couldn't change user role: ${error}`);
}); });
} else { } else {
this.state.db.collection("users") this.state.db
.collection("users")
.doc(this.state.editingUser.uid) .doc(this.state.editingUser.uid)
.collection("groups") .collection("groups")
.doc(this.props.match.params.groupId) .doc(this.props.match.params.groupId)
.update({ .update({
role: role, role: role,
}).then(() => { })
.then(() => {
let groupUsers = this.state.groupUsers; let groupUsers = this.state.groupUsers;
let userData; let userData;
if (this.state.editingUser.role === "owner") { if (this.state.editingUser.role === "owner") {
@@ -330,68 +352,101 @@ export default withRouter(class GroupPage extends Component {
editingUser: null, editingUser: null,
groupUsers: groupUsers, groupUsers: groupUsers,
}); });
}).catch((error) => { })
.catch((error) => {
this.setState({ this.setState({
editingUser: null, editingUser: null,
}); });
console.log(`Couldn't change user role: ${error}`) console.log(`Couldn't change user role: ${error}`);
}); });
} }
} }
} };
showDeleteGroup = () => { showDeleteGroup = () => {
this.setState({ this.setState({
showDeleteGroup: true, showDeleteGroup: true,
}); });
} };
hideDeleteGroup = () => { hideDeleteGroup = () => {
this.setState({ this.setState({
showDeleteGroup: false, showDeleteGroup: false,
}); });
} };
deleteGroup = () => { deleteGroup = () => {
this.setState({ this.setState({
deleteGroupLoading: true, deleteGroupLoading: true,
}); });
this.state.db.collection("groups") this.state.db
.collection("groups")
.doc(this.props.match.params.groupId) .doc(this.props.match.params.groupId)
.delete() .delete()
.then(() => { .then(() => {
this.props.history.push("/groups"); this.props.history.push("/groups");
}).catch((error) => { })
.catch((error) => {
console.log(`Couldn't delete group: ${error}`); console.log(`Couldn't delete group: ${error}`);
this.setState({ this.setState({
deleteGroupLoading: false, deleteGroupLoading: false,
}); });
});
};
showLeaveGroup = () => {
this.setState({
showLeaveGroup: true,
});
};
hideLeaveGroup = () => {
this.setState({
showLeaveGroup: false,
});
};
leaveGroup = () => {
this.setState({
leaveGroupLoading: true,
});
this.state.db
.collection("users")
.doc(this.props.user.uid)
.collection("groups")
.doc(this.props.match.params.groupId)
.delete()
.then(() => {
this.props.history.push("/groups");
}) })
} .catch((error) => {
console.log(`Couldn't leave group: ${error}`);
this.setState({
leaveGroupLoading: false,
});
});
};
render() { render() {
return ( return this.state.role === "none" ? (
(this.state.role === "none") ?
<Error404 /> <Error404 />
: ) : (
<div> <div>
<NavBar items={this.state.navbarItems} /> <NavBar items={this.state.navbarItems} />
<main> <main>
{ {!(this.state.role === null) && (
!(this.state.role === null) &&
<> <>
<div className="page-header"> <div className="page-header">
{ {this.state.editGroupName && this.state.role === "owner" ? (
this.state.editGroupName && this.state.role === "owner"
?
<h1 className="group-name-header-input-container"> <h1 className="group-name-header-input-container">
<input <input
type="text" type="text"
onChange={this.handleGroupNameChange} onChange={this.handleGroupNameChange}
value={this.state.groupName} value={this.state.groupName}
onKeyDown={this.handleInputKeypress} onKeyDown={this.handleInputKeypress}
ref={inputEl => (this.groupNameInput = inputEl)} ref={(inputEl) => (this.groupNameInput = inputEl)}
autoComplete="off" autoComplete="off"
/> />
<Button <Button
@@ -402,19 +457,17 @@ export default withRouter(class GroupPage extends Component {
loading={this.state.loading} loading={this.state.loading}
></Button> ></Button>
</h1> </h1>
: ) : (
<h1 onClick={this.state.role === "owner" ? this.editGroupName : () => {}}> <h1 onClick={this.state.role === "owner" ? this.editGroupName : () => {}}>
{this.state.groupName} {this.state.groupName}
{ {this.state.role === "owner" && (
this.state.role === "owner" &&
<span className="group-edit-icon"> <span className="group-edit-icon">
<EditRoundedIcon /> <EditRoundedIcon />
</span> </span>
} )}
</h1> </h1>
} )}
{ {this.state.role === "owner" ? (
this.state.role === "owner" &&
<div className="button-container"> <div className="button-container">
<LinkButton <LinkButton
to={`/groups/${this.props.match.params.groupId}/stats`} to={`/groups/${this.props.match.params.groupId}/stats`}
@@ -429,34 +482,38 @@ export default withRouter(class GroupPage extends Component {
title="Delete group" title="Delete group"
></Button> ></Button>
</div> </div>
} ) : (
<div className="button-container">
<Button
onClick={this.showLeaveGroup}
icon={<GroupRemoveRoundedIcon />}
className="button--round"
title="Leave group"
></Button>
</div> </div>
{ )}
this.state.joinCode && </div>
{this.state.joinCode && (
<div className="stat-row stat-row--inline"> <div className="stat-row stat-row--inline">
<p>Join code</p> <p>Join code</p>
<h2>{this.state.joinCode}</h2> <h2>{this.state.joinCode}</h2>
</div> </div>
} )}
{ {this.state.memberCount && (
this.state.memberCount &&
<div className="stat-row stat-row--inline"> <div className="stat-row stat-row--inline">
<h2>{this.state.memberCount}</h2> <h2>{this.state.memberCount}</h2>
<p> <p>
member member
{ this.state.memberCount !== 1 && "s" } {this.state.memberCount !== 1 && "s"}
</p> </p>
</div> </div>
} )}
<div> <div>
<h2>Sets</h2> <h2>Sets</h2>
{ {Object.keys(this.state.sets).length > 0 ? (
Object.keys(this.state.sets).length > 0
?
<div className="group-links-container"> <div className="group-links-container">
{ {Object.keys(this.state.sets)
Object.keys(this.state.sets)
.sort((a, b) => { .sort((a, b) => {
if (this.state.sets[a].displayName < this.state.sets[b].displayName) { if (this.state.sets[a].displayName < this.state.sets[b].displayName) {
return -1; return -1;
@@ -466,15 +523,10 @@ export default withRouter(class GroupPage extends Component {
} }
return 0; return 0;
}) })
.map((setId) => .map((setId) => (
<div key={setId} className="group-set-link"> <div key={setId} className="group-set-link">
<Link <Link to={`/sets/${setId}`}>{this.state.sets[setId].displayName}</Link>
to={`/sets/${setId}`} {this.state.role === "owner" && (
>
{this.state.sets[setId].displayName}
</Link>
{
this.state.role === "owner" &&
<Button <Button
className="button--no-background" className="button--no-background"
onClick={() => this.removeSet(setId)} onClick={() => this.removeSet(setId)}
@@ -483,144 +535,143 @@ export default withRouter(class GroupPage extends Component {
disabled={this.state.sets[setId].loading} disabled={this.state.sets[setId].loading}
title="Remove set" title="Remove set"
></Button> ></Button>
} )}
</div> </div>
) ))}
}
</div> </div>
: ) : (
<p> <p>This group doesn't have any sets yet!</p>
This group doesn't have any sets yet! )}
</p>
}
</div> </div>
{ {this.state.role === "owner" && (
this.state.role === "owner" &&
<> <>
<div> <div>
<h2>Members</h2> <h2>Members</h2>
{ {this.state.groupUsers && this.state.groupUsers.owners.length > 0 && (
this.state.groupUsers && this.state.groupUsers.owners.length > 0 &&
<> <>
<h3 className="group-role-header">Owners</h3> <h3 className="group-role-header">Owners</h3>
<div className="group-links-container"> <div className="group-links-container">
{ {this.state.groupUsers.owners.map((user, index) => (
this.state.groupUsers.owners.map((user, index) =>
<p <p
key={user.uid} key={user.uid}
className={`group-set-link ${user.uid !== this.state.user.uid ? "group-set-link--enabled" : ""}`} className={`group-set-link ${
onClick={user.uid === this.state.user.uid ? () => { } : () => this.showEditUserRole("owner", index)} user.uid !== this.state.user.uid ? "group-set-link--enabled" : ""
> }`}
{ onClick={
user.uid === this.state.user.uid user.uid === this.state.user.uid
? ? () => {}
: () => this.showEditUserRole("owner", index)
}
>
{user.uid === this.state.user.uid ? (
"You" "You"
: ) : (
<> <>
{user.displayName} {user.displayName}
<EditRoundedIcon /> <EditRoundedIcon />
</> </>
} )}
</p> </p>
) ))}
}
</div> </div>
</> </>
} )}
{ {this.state.groupUsers && this.state.groupUsers.contributors.length > 0 && (
this.state.groupUsers && this.state.groupUsers.contributors.length > 0 &&
<> <>
<h3 className="group-role-header">Contributors</h3> <h3 className="group-role-header">Contributors</h3>
<div className="group-links-container"> <div className="group-links-container">
{ {this.state.groupUsers.contributors.map((user, index) => (
this.state.groupUsers.contributors.map((user, index) =>
<p <p
key={user.uid} key={user.uid}
className={`group-set-link ${user.uid !== this.state.user.uid ? "group-set-link--enabled" : ""}`} className={`group-set-link ${
onClick={user.uid === this.state.user.uid ? () => { } : () => this.showEditUserRole("contributor", index)} user.uid !== this.state.user.uid ? "group-set-link--enabled" : ""
> }`}
{ onClick={
user.uid === this.state.user.uid user.uid === this.state.user.uid
? ? () => {}
: () => this.showEditUserRole("contributor", index)
}
>
{user.uid === this.state.user.uid ? (
"You" "You"
: ) : (
<> <>
{user.displayName} {user.displayName}
<EditRoundedIcon /> <EditRoundedIcon />
</> </>
} )}
</p> </p>
) ))}
}
</div> </div>
</> </>
} )}
{ {this.state.groupUsers && this.state.groupUsers.members.length > 0 && (
this.state.groupUsers && this.state.groupUsers.members.length > 0 &&
<> <>
<h3 className="group-role-header">Members</h3> <h3 className="group-role-header">Members</h3>
<div className="group-links-container"> <div className="group-links-container">
{ {this.state.groupUsers.members.map((user, index) => (
this.state.groupUsers.members.map((user, index) =>
<p <p
key={user.uid} key={user.uid}
className={`group-set-link ${user.uid !== this.state.user.uid ? "group-set-link--enabled" : ""}`} className={`group-set-link ${
onClick={user.uid === this.state.user.uid ? () => { } : () => this.showEditUserRole("member", index)} user.uid !== this.state.user.uid ? "group-set-link--enabled" : ""
> }`}
{ onClick={
user.uid === this.state.user.uid user.uid === this.state.user.uid
? ? () => {}
: () => this.showEditUserRole("member", index)
}
>
{user.uid === this.state.user.uid ? (
"You" "You"
: ) : (
<> <>
{user.displayName} {user.displayName}
<EditRoundedIcon /> <EditRoundedIcon />
</> </>
} )}
</p> </p>
) ))}
}
</div> </div>
</> </>
} )}
</div> </div>
{ {this.state.editingUser && (
this.state.editingUser &&
<> <>
<div className="overlay" onClick={this.hideEditUserRole}></div> <div className="overlay" onClick={this.hideEditUserRole}></div>
<div className="overlay-content options-list-overlay-content"> <div className="overlay-content options-list-overlay-content">
{ {["Owner", "Contributor", "Member", "Remove"].map((role) => (
["Owner", "Contributor", "Member", "Remove"].map((role) => <h3 key={role} onClick={() => this.editUserRole(role.toLowerCase())}>
<h3
key={role}
onClick={() => this.editUserRole(role.toLowerCase())}
>
{role} {role}
</h3> </h3>
) ))}
}
<div onClick={this.hideEditUserRole}> <div onClick={this.hideEditUserRole}>Cancel</div>
Cancel
</div>
</div> </div>
</> </>
} )}
{ {this.state.showDeleteGroup && (
this.state.showDeleteGroup &&
<ConfirmationDialog <ConfirmationDialog
yesFunction={this.deleteGroup} yesFunction={this.deleteGroup}
noFunction={this.hideDeleteGroup} noFunction={this.hideDeleteGroup}
message="Are you sure you want to delete this group?" message="Are you sure you want to delete this group?"
/> />
} )}
</> </>
} )}
{this.state.showLeaveGroup && (
<ConfirmationDialog
yesFunction={this.leaveGroup}
noFunction={this.hideLeaveGroup}
message="Are you sure you want to leave this group?"
loading={this.state.loading}
/>
)}
</> </>
} )}
</main> </main>
<Footer /> <Footer />
</div> </div>
) );
} }
}) }
);

View File

@@ -117,18 +117,42 @@ describe("Parandum Firestore database", () => {
await firebase.assertSucceeds(testDoc.get()); await firebase.assertSucceeds(testDoc.get());
}); });
it("Can delete current user's groups", async () => { it("Can delete current user's groups when not group owner", async () => {
const admin = getAdminFirestore();
await admin.collection("users").doc(myId).collection("groups").doc(groupOne).set({ role: "member" });
const db = getFirestore(myAuth); const db = getFirestore(myAuth);
const testDoc = db.collection("users").doc(myId).collection("groups").doc(groupOne); const testDoc = db.collection("users").doc(myId).collection("groups").doc(groupOne);
await firebase.assertSucceeds(testDoc.delete()); await firebase.assertSucceeds(testDoc.delete());
}); });
it("Can't delete other users' groups", async () => { it("Can't delete current user's groups when group owner", async () => {
const admin = getAdminFirestore();
await admin.collection("users").doc(myId).collection("groups").doc(groupOne).set({ role: "owner" });
const db = getFirestore(myAuth);
const testDoc = db.collection("users").doc(myId).collection("groups").doc(groupOne);
await firebase.assertFails(testDoc.delete());
});
it("Can't delete other users' groups when not group owner", async () => {
const admin = getAdminFirestore();
await admin.collection("users").doc(myId).collection("groups").doc(groupOne).set({ role: "member" });
const db = getFirestore(myAuth); const db = getFirestore(myAuth);
const testDoc = db.collection("users").doc(theirId).collection("groups").doc(groupOne); const testDoc = db.collection("users").doc(theirId).collection("groups").doc(groupOne);
await firebase.assertFails(testDoc.delete()); await firebase.assertFails(testDoc.delete());
}); });
it("Can delete other users' groups when group owner", async () => {
const admin = getAdminFirestore();
await admin.collection("users").doc(myId).collection("groups").doc(groupOne).set({ role: "owner" });
const db = getFirestore(myAuth);
const testDoc = db.collection("users").doc(theirId).collection("groups").doc(groupOne);
await firebase.assertSucceeds(testDoc.delete());
});
it("Can delete other users' groups when admin", async () => { it("Can delete other users' groups when admin", async () => {
const db = getFirestore(myAdminAuth); const db = getFirestore(myAdminAuth);
const testDoc = db.collection("users").doc(theirId).collection("groups").doc(groupOne); const testDoc = db.collection("users").doc(theirId).collection("groups").doc(groupOne);