Skip to content

Commit f6620bb

Browse files
committed
Lots of UX and UI fixes and tweaks for notifications and user permissions
1 parent a809c16 commit f6620bb

5 files changed

Lines changed: 131 additions & 31 deletions

File tree

backend/routes/rooms.py

Lines changed: 93 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ def list_rooms():
245245
pipeline.append({"$match": match})
246246
pipeline.append({"$addFields": {"_id_str": {"$toString": "$_id"}}})
247247
pipeline.append({"$lookup": {"from": shares_coll.name, "localField": "_id_str", "foreignField": "roomId", "as": "members"}})
248-
pipeline.append({"$addFields": {"memberCount": {"$size": {"$ifNull": ["$members", []]}}}})
248+
# memberCount = 1 (owner) + number of shared members
249+
pipeline.append({"$addFields": {"memberCount": {"$add": [1, {"$size": {"$ifNull": ["$members", []]}}]}}})
249250

250251
sort_map = {
251252
'updatedAt': ('updatedAt', -1),
@@ -325,7 +326,8 @@ def list_rooms():
325326
shared = list(rooms_coll.find({"_id": {"$in": oids}, "archived": {"$ne": True}}))
326327
def _fmt_single(r):
327328
rid = str(r["_id"])
328-
member_count = shares_coll.count_documents({"roomId": rid})
329+
# Count includes owner (1) + all shared members
330+
member_count = 1 + shares_coll.count_documents({"roomId": rid})
329331
my_role = None
330332
try:
331333
if str(r.get("ownerId")) == claims["sub"]:
@@ -407,9 +409,10 @@ def suggest_rooms():
407409
for r in cursor:
408410
rid = str(r.get("_id"))
409411
try:
410-
member_count = shares_coll.count_documents({"roomId": rid})
412+
# Count includes owner (1) + all shared members
413+
member_count = 1 + shares_coll.count_documents({"roomId": rid})
411414
except Exception:
412-
member_count = 0
415+
member_count = 1 # At least the owner
413416
rooms.append({
414417
"id": rid,
415418
"name": r.get("name"),
@@ -498,10 +501,29 @@ def share_room(roomId):
498501
results["errors"].append({"username": uname, "error": "user not found", "suggestions": suggs})
499502
continue
500503
uid = str(user["_id"])
504+
505+
if uid == str(room.get("ownerId")) or uname == room.get("ownerName"):
506+
results["errors"].append({"username": uname, "error": "cannot invite room owner (already has full access)"})
507+
continue
508+
509+
if uid == claims["sub"] or uname == claims.get("username"):
510+
results["errors"].append({"username": uname, "error": "cannot invite yourself"})
511+
continue
512+
501513
existing = shares_coll.find_one({"roomId": str(room["_id"]), "userId": uid})
502514
if existing:
503515
results["errors"].append({"username": uname, "error": "already shared with this user"})
504516
continue
517+
518+
if room.get("type") in ("private", "secure"):
519+
pending_invite = invites_coll.find_one({
520+
"roomId": str(room["_id"]),
521+
"invitedUserId": uid,
522+
"status": "pending"
523+
})
524+
if pending_invite:
525+
results["errors"].append({"username": uname, "error": "already has a pending invite to this room"})
526+
continue
505527

506528
if room.get("type") in ("private", "secure"):
507529
invite = {
@@ -2063,9 +2085,21 @@ def update_permissions(roomId):
20632085
target_user_id = data.get("userId")
20642086
if not target_user_id:
20652087
return jsonify({"status":"error","message":"Missing userId"}), 400
2088+
2089+
# Validate: cannot remove/modify yourself
2090+
if target_user_id == claims["sub"]:
2091+
return jsonify({"status":"error","message":"Cannot modify your own role. Use transfer ownership or leave room instead."}), 400
2092+
20662093
if "role" not in data or data.get("role") is None:
2094+
# Removing user
20672095
if target_user_id == room.get("ownerId"):
20682096
return jsonify({"status":"error","message":"Cannot remove owner"}), 400
2097+
2098+
# Check if user is actually a member
2099+
existing_share = shares_coll.find_one({"roomId": str(room["_id"]), "userId": target_user_id})
2100+
if not existing_share:
2101+
return jsonify({"status":"error","message":"User is not a member of this room"}), 400
2102+
20692103
shares_coll.delete_one({"roomId": str(room["_id"]), "userId": target_user_id})
20702104
try:
20712105
if _notification_allowed_for(target_user_id, 'removed'):
@@ -2116,13 +2150,24 @@ def update_permissions(roomId):
21162150
except Exception:
21172151
pass
21182152
return jsonify({"status":"ok","removed": target_user_id})
2153+
21192154
role = (data.get("role") or "").lower()
21202155
if role not in ("admin","editor","viewer"):
21212156
return jsonify({"status":"error","message":"Invalid role"}), 400
21222157
if target_user_id == room.get("ownerId"):
2123-
return jsonify({"status":"error","message":"Cannot change owner role"}), 400
2158+
return jsonify({"status":"error","message":"Cannot change owner role. Use transfer ownership instead."}), 400
21242159
if role == "admin" and caller_role != "owner":
2125-
return jsonify({"status":"error","message":"Only owner may invite admin role"}), 403
2160+
return jsonify({"status":"error","message":"Only owner may assign admin role"}), 403
2161+
if role == "owner":
2162+
return jsonify({"status":"error","message":"Cannot assign owner role. Use transfer ownership endpoint instead."}), 400
2163+
2164+
existing_share = shares_coll.find_one({"roomId": str(room["_id"]), "userId": target_user_id})
2165+
if not existing_share:
2166+
return jsonify({"status":"error","message":"User is not a member of this room"}), 400
2167+
2168+
if existing_share.get("role") == role:
2169+
return jsonify({"status":"ok","message":"User already has this role","userId": target_user_id, "role": role})
2170+
21262171
shares_coll.update_one({"roomId": str(room["_id"]), "userId": target_user_id}, {"$set": {"role": role}}, upsert=False)
21272172
try:
21282173
if _notification_allowed_for(target_user_id, 'role_changed'):
@@ -2325,27 +2370,48 @@ def transfer_ownership(roomId):
23252370

23262371
data = request.get_json() or {}
23272372
target_username = data.get("username")
2373+
2374+
if target_username == claims.get("username") or target_username == room.get("ownerName"):
2375+
return jsonify({"status":"error","message":"You are already the owner of this room"}), 400
2376+
23282377
target_user = users_coll.find_one({"username": target_username})
23292378
if not target_user:
23302379
return jsonify({"status":"error","message":"Target user not found"}), 404
2331-
member = shares_coll.find_one({"roomId": str(room["_id"]), "userId": str(target_user["_id"])})
2380+
2381+
target_user_id = str(target_user["_id"])
2382+
2383+
member = shares_coll.find_one({"roomId": str(room["_id"]), "userId": target_user_id})
23322384
if not member:
2385+
pending_invite = invites_coll.find_one({
2386+
"roomId": str(room["_id"]),
2387+
"invitedUserId": target_user_id,
2388+
"status": "pending"
2389+
})
2390+
if pending_invite:
2391+
return jsonify({"status":"error","message":"Cannot transfer ownership to user with pending invite. They must accept the invite first."}), 400
23332392
return jsonify({"status":"error","message":"Target user is not a member of the room"}), 400
2334-
rooms_coll.update_one({"_id": ObjectId(roomId)}, {"$set": {"ownerId": str(target_user["_id"]), "ownerName": target_user["username"], "updatedAt": datetime.utcnow()}})
2393+
2394+
rooms_coll.update_one({"_id": ObjectId(roomId)}, {"$set": {"ownerId": target_user_id, "ownerName": target_user["username"], "updatedAt": datetime.utcnow()}})
23352395

23362396
# Remove the new owner from shares_coll (owners don't have share records)
2337-
shares_coll.delete_one({"roomId": str(room["_id"]), "userId": str(target_user["_id"])})
2397+
shares_coll.delete_one({"roomId": str(room["_id"]), "userId": target_user_id})
23382398

2339-
# Add the old owner to shares_coll as editor (create new share record for former owner)
2340-
shares_coll.insert_one({
2341-
"roomId": str(room["_id"]),
2342-
"userId": claims["sub"],
2343-
"username": claims.get("username"),
2344-
"role": "editor",
2345-
"createdAt": datetime.utcnow()
2346-
})
2399+
# Ensure the old owner is downgraded to editor in shares_coll
2400+
shares_coll.update_one(
2401+
{"roomId": str(room["_id"]), "userId": claims["sub"]},
2402+
{"$set": {
2403+
"roomId": str(room["_id"]),
2404+
"userId": claims["sub"],
2405+
"username": claims.get("username"),
2406+
"role": "editor",
2407+
"updatedAt": datetime.utcnow()
2408+
}, "$setOnInsert": {
2409+
"createdAt": datetime.utcnow()
2410+
}},
2411+
upsert=True
2412+
)
23472413
notifications_coll.insert_one({
2348-
"userId": str(target_user["_id"]),
2414+
"userId": target_user_id,
23492415
"type": "ownership_transfer",
23502416
"message": f"You are now the owner of room '{room.get('name')}'",
23512417
"link": f"/rooms/{roomId}",
@@ -2362,7 +2428,7 @@ def transfer_ownership(roomId):
23622428
})
23632429
# Send real-time events for ownership transfer
23642430
try:
2365-
push_to_user(str(target_user["_id"]), 'role_changed', {
2431+
push_to_user(target_user_id, 'role_changed', {
23662432
'roomId': roomId,
23672433
'roomName': room.get('name'),
23682434
'newRole': 'owner',
@@ -2387,6 +2453,14 @@ def transfer_ownership(roomId):
23872453
})
23882454
except Exception:
23892455
pass
2456+
try:
2457+
# Also emit member_role_changed to trigger refreshMembers in RoomSettings
2458+
push_to_room(roomId, 'member_role_changed', {
2459+
'roomId': roomId,
2460+
'message': f"Ownership transferred to {target_user['username']}"
2461+
})
2462+
except Exception:
2463+
pass
23902464
return jsonify({"status":"ok"})
23912465

23922466
@rooms_bp.route("/rooms/<roomId>/leave", methods=["POST"])

backend/tests/integration/test_rooms_api.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,13 +114,17 @@ def test_join_room(self, client, mock_mongodb, auth_headers, test_room):
114114
# Note: /rooms/{id}/join endpoint may not exist, check actual routes
115115
assert response.status_code in [200, 404, 405, 409]
116116

117-
def test_leave_room(self, client, mock_mongodb, auth_headers, test_room):
117+
def test_leave_room(self, client, mock_mongodb, auth_headers, test_room, test_user):
118118
room_id = str(test_room["_id"])
119119
response = client.post(f'/rooms/{room_id}/leave', headers=auth_headers)
120120

121-
assert response.status_code == 200
121+
# Owner cannot leave their own room (must transfer ownership first)
122+
# test_room is owned by test_user, and auth_headers is for test_user
123+
assert response.status_code == 400
122124
data = response.get_json()
123-
assert 'message' in data or 'status' in data
125+
assert 'status' in data
126+
assert data['status'] == 'error'
127+
assert 'ownership' in data.get('message', '').lower()
124128

125129
def test_search_rooms(self, client, mock_mongodb, auth_headers, test_room):
126130
response = client.get(f'/rooms?search={test_room["name"]}', headers=auth_headers)

frontend/src/components/NotificationsMenu.jsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ export default function NotificationsMenu({ auth }) {
1010
const [items, setItems] = useState([]);
1111
const [unreadCount, setUnreadCount] = useState(0);
1212
const [highlightedIds, setHighlightedIds] = useState(new Set());
13+
const buttonRef = React.useRef(null);
14+
const isMenuOpenRef = React.useRef(false);
1315

1416
async function refresh() {
1517
if (!auth?.token) return;
1618
try {
1719
const res = await listNotifications(auth.token);
1820
setItems(res);
19-
// highlight unread items
2021
const unreadIds = new Set((res || []).filter(r => !r.read).map(r => r.id));
2122
setHighlightedIds(unreadIds);
22-
// Update unread count immediately
23+
2324
const count = (res || []).filter(r => !r.read).length;
2425
setUnreadCount(count);
2526
} catch (err) {
@@ -38,6 +39,11 @@ export default function NotificationsMenu({ auth }) {
3839
setHighlightedIds(prev => new Set(Array.from(prev).concat([id])));
3940
// Increment unread count for new notification
4041
setUnreadCount(prev => prev + 1);
42+
43+
if (buttonRef.current && !isMenuOpenRef.current) {
44+
setAnchor(buttonRef.current);
45+
isMenuOpenRef.current = true;
46+
}
4147
});
4248
setSocketToken(auth.token);
4349
getSocket(auth.token);
@@ -46,10 +52,14 @@ export default function NotificationsMenu({ auth }) {
4652

4753
async function handleOpen(e) {
4854
setAnchor(e.currentTarget);
55+
isMenuOpenRef.current = true;
4956
await refresh();
5057
}
5158

52-
function handleClose() { setAnchor(null); }
59+
function handleClose() {
60+
setAnchor(null);
61+
isMenuOpenRef.current = false;
62+
}
5363

5464
const [dialogOpen, setDialogOpen] = useState(false);
5565
const [activeNotif, setActiveNotif] = useState(null);
@@ -61,12 +71,10 @@ export default function NotificationsMenu({ auth }) {
6171
const roomId = (n?.link || '').split('/').pop();
6272
const inv = (invites || []).find(i => i.roomId === roomId && i.status === 'pending');
6373
if (!inv) {
64-
// No pending invite: mark related invite notifications as read so the dialog won't re-open
6574
try { await _markInviteNotificationsReadByRoom(roomId); } catch (err) { console.error('mark read by room failed', err); }
6675
await refresh();
6776
return;
6877
}
69-
// There is a pending invite: open dialog and attach resolved invite id so accept/decline can act directly
7078
setActiveNotif({ ...n, relatedId: inv.id });
7179
setDialogOpen(true);
7280
return;
@@ -75,12 +83,10 @@ export default function NotificationsMenu({ auth }) {
7583
return;
7684
}
7785
}
78-
// For other notifications: mark as read (dismiss/unhighlight) but do NOT redirect.
7986
try {
8087
if (!n.read) {
8188
await markNotificationRead(auth.token, n.id);
8289
setItems(prev => prev.map(it => it.id === n.id ? { ...it, read: true } : it));
83-
// Decrement unread count when marking as read
8490
setUnreadCount(prev => Math.max(0, prev - 1));
8591
}
8692
} catch (e) { console.error('mark read failed', e); }
@@ -92,7 +98,6 @@ export default function NotificationsMenu({ auth }) {
9298
await deleteNotification(auth.token, n.id);
9399
setItems(prev => prev.filter(it => it.id !== n.id));
94100
setHighlightedIds(prev => { const s = new Set(Array.from(prev)); s.delete(n.id); return s; });
95-
// Decrement unread count if the deleted notification was unread
96101
if (!n.read) {
97102
setUnreadCount(prev => Math.max(0, prev - 1));
98103
}
@@ -168,7 +173,7 @@ export default function NotificationsMenu({ auth }) {
168173

169174
return (
170175
<>
171-
<IconButton color="inherit" onClick={handleOpen} sx={{ '&:hover': { boxShadow: '0 2px 8px rgba(37,216,197,0.12)', transform: 'translateY(-1px)' }, transition: 'all 120ms ease' }}>
176+
<IconButton ref={buttonRef} color="inherit" onClick={handleOpen} sx={{ '&:hover': { boxShadow: '0 2px 8px rgba(37,216,197,0.12)', transform: 'translateY(-1px)' }, transition: 'all 120ms ease' }}>
172177
<Badge badgeContent={unreadCount} color="error">
173178
<NotificationsIcon />
174179
</Badge>

frontend/src/pages/Room.jsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,16 +153,32 @@ export default function Room({ auth }) {
153153
load();
154154
}
155155
};
156+
const onRoomDeleted = (payload) => {
157+
if (payload?.roomId === roomId) {
158+
console.log('Room deleted:', payload);
159+
// Show notification and redirect to dashboard
160+
try {
161+
window.dispatchEvent(new CustomEvent('rescanvas:notify', {
162+
detail: { message: 'This room was deleted by the owner', duration: 4000 }
163+
}));
164+
} catch (e) { }
165+
setTimeout(() => {
166+
navigate('/dashboard');
167+
}, 1000);
168+
}
169+
};
156170
sock.on("stroke", onStroke);
157171
sock.on("role_changed", onRoleChanged);
158172
sock.on("user_removed_from_room", onUserRemoved);
159173
sock.on("room_updated", onRoomUpdated);
174+
sock.on("room_deleted", onRoomDeleted);
160175
return () => {
161176
try {
162177
sock.off("stroke", onStroke);
163178
sock.off("role_changed", onRoleChanged);
164179
sock.off("user_removed_from_room", onUserRemoved);
165180
sock.off("room_updated", onRoomUpdated);
181+
sock.off("room_deleted", onRoomDeleted);
166182
sock.emit("leave_room", { roomId });
167183
} catch (_) { }
168184
};

frontend/src/pages/RoomSettings.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export default function RoomSettings() {
106106
setDescription(payload.room.description || '');
107107
setType(payload.room.type || 'public');
108108
}
109+
refreshMembers();
109110
}
110111
};
111112

0 commit comments

Comments
 (0)