Skip to content

Commit f3aebd7

Browse files
committed
Lots of UX and UI fixes and tweaks including fixing more bugs with user permissions
1 parent 4a2a332 commit f3aebd7

7 files changed

Lines changed: 163 additions & 51 deletions

File tree

backend/routes/rooms.py

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -446,9 +446,14 @@ def share_room(roomId):
446446
claims = g.token_claims
447447
room = g.current_room
448448

449-
inviter_share = shares_coll.find_one({"roomId": str(room["_id"]), "userId": claims["sub"]})
450-
if not inviter_share or inviter_share.get("role") not in ("owner","admin","editor"):
451-
return jsonify({"status":"error","message":"Forbidden: Only room owner, admin, or editor can share"}), 403
449+
is_owner = (str(room.get("ownerId")) == claims["sub"])
450+
if not is_owner:
451+
inviter_share = shares_coll.find_one({"roomId": str(room["_id"]), "userId": claims["sub"]})
452+
if not inviter_share or inviter_share.get("role") not in ("admin","editor"):
453+
return jsonify({"status":"error","message":"Forbidden: Only room owner, admin, or editor can share"}), 403
454+
inviter_role = inviter_share.get("role")
455+
else:
456+
inviter_role = "owner"
452457

453458
data = request.get_json(force=True) or {}
454459
usernames = data.get("usernames") or []
@@ -474,7 +479,7 @@ def share_room(roomId):
474479
if role == "owner":
475480
return jsonify({"status":"error","message":"Cannot invite as owner; use transfer endpoint"}), 400
476481

477-
if role == "admin" and inviter_share.get("role") != "owner":
482+
if role == "admin" and inviter_role != "owner":
478483
return jsonify({"status":"error","message":"Forbidden: Only the room owner may invite admin users"}), 403
479484

480485
results = {"invited": [], "updated": [], "errors": []}
@@ -1959,26 +1964,47 @@ def get_room_members(roomId):
19591964
user = g.current_user
19601965
claims = g.token_claims
19611966
room = g.current_room
1967+
1968+
members = []
1969+
1970+
# Add the owner first
1971+
owner_id = None
1972+
try:
1973+
owner_id = room.get("ownerId")
1974+
owner_name = room.get("ownerName")
1975+
if owner_id:
1976+
members.append({
1977+
"username": owner_name or "Unknown",
1978+
"userId": owner_id,
1979+
"role": "owner"
1980+
})
1981+
except Exception as e:
1982+
logger.error(f"Failed to add owner to members list: {e}")
1983+
1984+
# Add all other members from shares_coll (excluding owner if they have a share record)
19621985
try:
19631986
cursor = shares_coll.find({"roomId": str(room["_id"])}, {"username": 1, "userId": 1, "role": 1})
1964-
members = []
19651987
for m in cursor:
19661988
if not m: continue
1989+
# Skip if this is the owner (owners shouldn't have share records, but filter just in case)
1990+
if owner_id and m.get("userId") == owner_id:
1991+
continue
19671992
members.append({
19681993
"username": m.get("username"),
19691994
"userId": m.get("userId"),
19701995
"role": m.get("role") or "editor"
19711996
})
1972-
except Exception:
1973-
members = []
1997+
except Exception as e:
1998+
logger.error(f"Failed to fetch members from shares_coll: {e}")
1999+
19742000
return jsonify({"status":"ok","members": members})
19752001

19762002
@rooms_bp.route("/rooms/<roomId>/permissions", methods=["PATCH"])
19772003
@require_auth
19782004
@require_room_access(room_id_param="roomId")
19792005
@validate_request_data({
19802006
"userId": {"validator": validate_member_id, "required": True},
1981-
"role": {"validator": validate_optional_string, "required": False}
2007+
"role": {"validator": validate_optional_string(), "required": False}
19822008
})
19832009
def update_permissions(roomId):
19842010
"""
@@ -2002,8 +2028,8 @@ def update_permissions(roomId):
20022028
caller_role = (shares_coll.find_one({"roomId": str(room["_id"]), "$or": [{"userId": claims["sub"]}, {"username": claims["sub"]}]}) or {}).get("role")
20032029
except Exception:
20042030
caller_role = None
2005-
if caller_role not in ("owner", "editor", "admin"):
2006-
return jsonify({"status":"error","message":"Forbidden"}), 403
2031+
if caller_role != "owner":
2032+
return jsonify({"status":"error","message":"Only the room owner can change member roles"}), 403
20072033
data = request.get_json() or {}
20082034
target_user_id = data.get("userId")
20092035
if not target_user_id:
@@ -2232,8 +2258,18 @@ def transfer_ownership(roomId):
22322258
if not member:
22332259
return jsonify({"status":"error","message":"Target user is not a member of the room"}), 400
22342260
rooms_coll.update_one({"_id": ObjectId(roomId)}, {"$set": {"ownerId": str(target_user["_id"]), "ownerName": target_user["username"], "updatedAt": datetime.utcnow()}})
2235-
shares_coll.update_one({"roomId": str(room["_id"]), "userId": str(target_user["_id"])}, {"$set": {"role": "owner"}})
2236-
shares_coll.update_one({"roomId": str(room["_id"]), "userId": claims["sub"]}, {"$set": {"role": "editor"}})
2261+
2262+
# Remove the new owner from shares_coll (owners don't have share records)
2263+
shares_coll.delete_one({"roomId": str(room["_id"]), "userId": str(target_user["_id"])})
2264+
2265+
# Add the old owner to shares_coll as editor (create new share record for former owner)
2266+
shares_coll.insert_one({
2267+
"roomId": str(room["_id"]),
2268+
"userId": claims["sub"],
2269+
"username": claims.get("username"),
2270+
"role": "editor",
2271+
"createdAt": datetime.utcnow()
2272+
})
22372273
notifications_coll.insert_one({
22382274
"userId": str(target_user["_id"]),
22392275
"type": "ownership_transfer",
@@ -2268,6 +2304,10 @@ def leave_room(roomId):
22682304
claims = g.token_claims
22692305
room = g.current_room
22702306
user_id = claims["sub"]
2307+
2308+
if str(room.get("ownerId")) == user_id:
2309+
return jsonify({"status":"error","message":"Owner must transfer ownership before leaving"}), 400
2310+
22712311
try:
22722312
share = shares_coll.find_one({"roomId": str(room["_id"]), "$or": [{"userId": user_id}, {"username": user_id}]})
22732313
except Exception:
@@ -2277,8 +2317,6 @@ def leave_room(roomId):
22772317
logger.debug("leave_room: user %s not a member of public room %s; treating as no-op", user_id, str(room.get("_id")))
22782318
return jsonify({"status":"ok","message":"Not a member (noop)", "removed": False}), 200
22792319
return jsonify({"status":"error","message":"Not a member"}), 400
2280-
if share.get("role") == "owner":
2281-
return jsonify({"status":"error","message":"Owner must transfer ownership before leaving"}), 400
22822320
try:
22832321
del_q = {"roomId": str(room["_id"]), "$or": [{"userId": user_id}, {"username": user_id}]}
22842322
shares_coll.delete_one(del_q)

frontend/src/components/Layout.jsx

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,40 @@ export default function Layout() {
252252
return () => window.removeEventListener('storage', storageHandler);
253253
}, []);
254254

255+
// Global error handler to catch unhandled promise rejections and convert to user-friendly notifications
256+
useEffect(() => {
257+
const handleUnhandledRejection = (event) => {
258+
console.error('Unhandled promise rejection:', event.reason);
259+
260+
// Prevent the default error overlay from showing
261+
event.preventDefault();
262+
263+
// Extract error message
264+
const error = event.reason;
265+
let message = 'An unexpected error occurred';
266+
267+
try {
268+
if (error && typeof error === 'object') {
269+
if (error.message) {
270+
message = error.message;
271+
} else if (error.body && error.body.message) {
272+
message = error.body.message;
273+
}
274+
} else if (typeof error === 'string') {
275+
message = error;
276+
}
277+
} catch (e) {
278+
console.warn('Error parsing rejection:', e);
279+
}
280+
281+
// Show user-friendly notification instead of error overlay
282+
setGlobalSnack({ open: true, message, duration: 5000 });
283+
};
284+
285+
window.addEventListener('unhandledrejection', handleUnhandledRejection);
286+
return () => window.removeEventListener('unhandledrejection', handleUnhandledRejection);
287+
}, []);
288+
255289
function handleAuthed(j) {
256290
const nxt = { token: j.token, user: j.user };
257291
setAuth(nxt);
@@ -273,7 +307,10 @@ export default function Layout() {
273307
return (
274308
<ThemeProvider theme={theme}>
275309
<Box sx={{
276-
minHeight: '100vh', display: 'flex', flexDirection: 'column',
310+
height: '100vh',
311+
display: 'flex',
312+
flexDirection: 'column',
313+
overflow: 'hidden',
277314
// CSS variable for footer height so multiple elements can stay in sync
278315
'--rescanvas-footer-height': '85px'
279316
}}>
@@ -383,10 +420,8 @@ export default function Layout() {
383420
</DialogActions>
384421
</Dialog>
385422
<AppBreadcrumbs auth={auth} />
386-
{/* Central area: always allow scrolling here and reserve space for the footer
387-
so page content can't be obscured by the sticky bottom bar. Individual
388-
pages may still provide their own scroll containers if desired. */}
389-
<Box className="page-scroll-container" sx={{ flex: 1, overflow: 'auto', pb: 'calc(var(--rescanvas-footer-height) + 1000px)' }}>
423+
{/* Central area: scrollable content between header and footer */}
424+
<Box className="page-scroll-container" sx={{ flex: 1, overflow: 'auto' }}>
390425
<Routes>
391426
<Route path="/" element={<HomeRedirect auth={auth} />} />
392427
<Route path="/blog" element={<Blog />} />
@@ -402,14 +437,7 @@ export default function Layout() {
402437
/>
403438
<Route path="/dashboard" element={
404439
<ProtectedRoute auth={auth}>
405-
{/*
406-
Use an explicit calc() height for the dashboard scroll container so it
407-
reliably scrolls independently of document/html overflow settings.
408-
Reserve space for the top bar + breadcrumb + footer (approx 200px).
409-
*/}
410-
<Box sx={{ height: 'calc(100vh - 225px)', overflow: 'auto' }} className="page-scrollable">
411-
<Dashboard auth={auth} />
412-
</Box>
440+
<Dashboard auth={auth} />
413441
</ProtectedRoute>
414442
} />
415443
<Route path="/rooms" element={

frontend/src/pages/RoomSettings.jsx

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,13 +120,21 @@ export default function RoomSettings() {
120120
setRoom(res.room);
121121
}
122122

123+
try {
124+
window.dispatchEvent(new CustomEvent('rescanvas:notify', { detail: { message: 'Room settings saved', duration: 2000 } }));
125+
} catch (evErr) { console.warn('notify failed', evErr); }
126+
123127
setTimeout(() => navigate(`/rooms/${id}`), 250);
124128
} catch (e) {
125129
console.error('Failed to save room settings:', e);
126130
if (e?.message && e.message.toLowerCase().includes('forbidden')) {
127131
setForbiddenMessage('You do not have permission to change settings for this room.');
128132
setForbiddenRedirect(`/rooms/${id}`);
129133
setForbiddenOpen(true);
134+
} else {
135+
try {
136+
window.dispatchEvent(new CustomEvent('rescanvas:notify', { detail: { message: 'Failed to save settings: ' + (e?.message || e), duration: 4000 } }));
137+
} catch (evErr) { console.warn('notify failed', evErr); }
130138
}
131139
}
132140
}
@@ -135,18 +143,30 @@ export default function RoomSettings() {
135143
try {
136144
await updatePermissions(null, id, { userId, role });
137145
await refreshMembers();
146+
try {
147+
window.dispatchEvent(new CustomEvent('rescanvas:notify', { detail: { message: 'Member role updated', duration: 2000 } }));
148+
} catch (evErr) { console.warn('notify failed', evErr); }
138149
} catch (e) {
139150
console.error('Failed to change role', e);
140-
throw e;
151+
try {
152+
window.dispatchEvent(new CustomEvent('rescanvas:notify', { detail: { message: 'Failed to change role: ' + (e?.message || e), duration: 4000 } }));
153+
} catch (evErr) { console.warn('notify failed', evErr); }
154+
await refreshMembers();
141155
}
142156
}
143157

144158
async function kickMember(userId) {
145159
try {
146160
await updatePermissions(null, id, { userId });
147161
await refreshMembers();
162+
try {
163+
window.dispatchEvent(new CustomEvent('rescanvas:notify', { detail: { message: 'Member removed', duration: 2000 } }));
164+
} catch (evErr) { console.warn('notify failed', evErr); }
148165
} catch (e) {
149166
console.error('Failed to remove member', e);
167+
try {
168+
window.dispatchEvent(new CustomEvent('rescanvas:notify', { detail: { message: 'Failed to remove member: ' + (e?.message || e), duration: 4000 } }));
169+
} catch (evErr) { console.warn('notify failed', evErr); }
150170
}
151171
}
152172

@@ -182,7 +202,7 @@ export default function RoomSettings() {
182202
if (!room) return <Typography>Loading...</Typography>;
183203

184204
return (
185-
<Box sx={{ p: 2, height: 'calc(100vh - 260px)', overflow: 'auto' }}>
205+
<Box sx={{ p: 2 }}>
186206
<Paper sx={{ p: 2 }}>
187207
<Typography variant="h6">Settings for {room.name}</Typography>
188208
<Tooltip title={isEditor ? '' : 'Only owners and editors may change name'}>
@@ -223,8 +243,8 @@ export default function RoomSettings() {
223243
<ListItem key={m.userId} secondaryAction={(
224244
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
225245
{m.role !== 'owner' && (
226-
myRole === 'viewer' ? (
227-
<Tooltip title="Viewers cannot change member roles">
246+
!isOwner ? (
247+
<Tooltip title="Only the room owner can change member roles">
228248
<span>
229249
<TextField size="small" select value={m.role || 'editor'} sx={{ minWidth: 120, mr: 1 }} disabled />
230250
</span>
@@ -236,13 +256,16 @@ export default function RoomSettings() {
236256
value={m.role || 'editor'}
237257
onChange={(e) => changeMemberRole(m.userId, e.target.value)}
238258
sx={{ minWidth: 120, mr: 1 }}
239-
/>
259+
>
260+
<MenuItem value="editor">Editor</MenuItem>
261+
<MenuItem value="viewer">Viewer</MenuItem>
262+
</TextField>
240263
)
241264
)}
242265

243266
{m.role !== 'owner' && (
244-
myRole === 'viewer' ? (
245-
<Tooltip title="Viewers cannot remove members">
267+
!isOwner ? (
268+
<Tooltip title="Only the room owner can remove members">
246269
<span>
247270
<IconButton edge="end" aria-label="kick" disabled>
248271
<DeleteIcon />
@@ -368,14 +391,22 @@ export default function RoomSettings() {
368391
if (!errors.length) {
369392
setInviteSelected([]); setInviteInput('');
370393
await refreshMembers();
394+
try {
395+
window.dispatchEvent(new CustomEvent('rescanvas:notify', { detail: { message: 'Invitations sent successfully', duration: 3000 } }));
396+
} catch (evErr) { console.warn('notify failed', evErr); }
371397
} else {
372398
// keep dialog state for corrections
373399
const succeeded = (res.invited || []).map(i => i.username).concat((res.updated || []).map(i => i.username));
374400
if (succeeded.length) {
375401
await refreshMembers();
376402
}
377403
}
378-
} catch (e) { console.error('Invite failed', e); alert('Invite failed: ' + (e?.message || e)); }
404+
} catch (e) {
405+
console.error('Invite failed', e);
406+
try {
407+
window.dispatchEvent(new CustomEvent('rescanvas:notify', { detail: { message: 'Failed to send invites: ' + (e?.message || e), duration: 4000 } }));
408+
} catch (evErr) { console.warn('notify failed', evErr); }
409+
}
379410
}}
380411
disabled={!canInvite}
381412
>Send invites / Add to room</Button>

frontend/src/styles/App.css

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,12 @@ li {
106106
scrollbar-color: #007bff #f0f2f5;
107107
}
108108

109-
html, body, #root {
109+
html,
110+
body,
111+
#root {
110112
margin: 0;
111113
padding: 0;
112114
height: 100%;
113115
overflow: hidden;
114-
font-family: Monaco, Menlo, 'Courier New', monospace;
115-
}
116+
font-family: 'Comic Sans MS', 'Comic Sans', cursive, sans-serif;
117+
}

frontend/src/styles/Canvas.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@
243243
}
244244

245245
body {
246-
font-family: Monaco, Menlo, 'Courier New', monospace;
246+
font-family: 'Comic Sans MS', 'Comic Sans', cursive, sans-serif;
247247
}
248248

249249
.Canvas-toolbar::-webkit-scrollbar {
@@ -314,4 +314,4 @@ body {
314314
background: rgba(0, 123, 255, 0.1);
315315
color: #007bff;
316316
transform: scale(1.05);
317-
}
317+
}

0 commit comments

Comments
 (0)