-
Notifications
You must be signed in to change notification settings - Fork 5
feat: admin/owner role management on group page #912
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c10d5de
a576211
6691ac6
712d379
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -94,40 +94,47 @@ export class WebhookController { | |||||||||||||||||||||||||||||||||||||||||||||||
| axios.post(new URL("blabsy", process.env.ANCHR_URL).toString(), req.body) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Early duplicate check | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (adapter.lockedIds.includes(id)) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const mapping = Object.values(adapter.mapping).find( | ||||||||||||||||||||||||||||||||||||||||||||||||
| (m) => m.schemaId === schemaId, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!mapping) throw new Error(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const tableName = mapping.tableName + "s"; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // For chats, skip the lock check and use timestamp comparison instead | ||||||||||||||||||||||||||||||||||||||||||||||||
| const isChatData = tableName === "chats"; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isChatData && adapter.lockedIds.includes(id)) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Webhook skipped - ID ${id} already locked`); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return res.status(200).json({ success: true, skipped: true }); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Processing webhook for ID: ${id}`); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Lock the global ID immediately to prevent duplicates | ||||||||||||||||||||||||||||||||||||||||||||||||
| adapter.addToLockedIds(id); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const mapping = Object.values(adapter.mapping).find( | ||||||||||||||||||||||||||||||||||||||||||||||||
| (m) => m.schemaId === schemaId, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!mapping) throw new Error(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const tableName = mapping.tableName + "s"; | ||||||||||||||||||||||||||||||||||||||||||||||||
| // Lock the global ID immediately to prevent duplicates (non-chat) | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (!isChatData) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| adapter.addToLockedIds(id); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const local = await adapter.fromGlobal({ data, mapping }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("Webhook data received:", { | ||||||||||||||||||||||||||||||||||||||||||||||||
| globalId: id, | ||||||||||||||||||||||||||||||||||||||||||||||||
| tableName, | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("Webhook data received:", { | ||||||||||||||||||||||||||||||||||||||||||||||||
| globalId: id, | ||||||||||||||||||||||||||||||||||||||||||||||||
| tableName, | ||||||||||||||||||||||||||||||||||||||||||||||||
| hasEname: !!local.data.ename, | ||||||||||||||||||||||||||||||||||||||||||||||||
| ename: local.data.ename | ||||||||||||||||||||||||||||||||||||||||||||||||
| ename: local.data.ename | ||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Get the local ID from the mapping database | ||||||||||||||||||||||||||||||||||||||||||||||||
| const localId = await adapter.mappingDb.getLocalId(id); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (localId) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`LOCAL, updating - ID: ${id}, LocalID: ${localId}`); | ||||||||||||||||||||||||||||||||||||||||||||||||
| // Lock local ID early to prevent duplicate processing | ||||||||||||||||||||||||||||||||||||||||||||||||
| adapter.addToLockedIds(localId); | ||||||||||||||||||||||||||||||||||||||||||||||||
| await this.updateRecord(tableName, localId, local.data); | ||||||||||||||||||||||||||||||||||||||||||||||||
| if (isChatData) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| await this.updateChatIfNewer(localId, local.data); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||
| adapter.addToLockedIds(localId); | ||||||||||||||||||||||||||||||||||||||||||||||||
| await this.updateRecord(tableName, localId, local.data); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`NOT LOCAL, creating - ID: ${id}`); | ||||||||||||||||||||||||||||||||||||||||||||||||
| await this.createRecord(tableName, local.data, req.body.id); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -252,6 +259,33 @@ export class WebhookController { | |||||||||||||||||||||||||||||||||||||||||||||||
| const mappedData = await this.mapDataToFirebase(tableName, data); | ||||||||||||||||||||||||||||||||||||||||||||||||
| await docRef.update(mappedData); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| private async updateChatIfNewer(localId: string, data: any) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const docRef = this.db.collection("chats").doc(localId); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const docSnapshot = await docRef.get(); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (!docSnapshot.exists) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.warn(`Chat document '${localId}' does not exist. Skipping update.`); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const existing = docSnapshot.data(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| const mappedData = this.mapChatData(data, Timestamp.now()); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // Compare updatedAt timestamps - accept if incoming is more recent | ||||||||||||||||||||||||||||||||||||||||||||||||
| const existingUpdatedAt = existing?.updatedAt?.toMillis?.() ?? 0; | ||||||||||||||||||||||||||||||||||||||||||||||||
| const incomingUpdatedAt = mappedData.updatedAt?.toMillis?.() ?? 0; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| if (incomingUpdatedAt < existingUpdatedAt) { | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Chat ${localId} webhook skipped - local data is more recent (local: ${existingUpdatedAt}, incoming: ${incomingUpdatedAt})`); | ||||||||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+273
to
+282
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Timestamp comparison uses current time instead of incoming webhook's timestamp. The comparison is ineffective because
Since If the intent is to skip stale webhooks, you need to compare the original Proposed fix: Compare using incoming webhook's timestamp private async updateChatIfNewer(localId: string, data: any) {
const docRef = this.db.collection("chats").doc(localId);
const docSnapshot = await docRef.get();
if (!docSnapshot.exists) {
console.warn(`Chat document '${localId}' does not exist. Skipping update.`);
return;
}
const existing = docSnapshot.data();
- const mappedData = this.mapChatData(data, Timestamp.now());
-
- // Compare updatedAt timestamps - accept if incoming is more recent
const existingUpdatedAt = existing?.updatedAt?.toMillis?.() ?? 0;
- const incomingUpdatedAt = mappedData.updatedAt?.toMillis?.() ?? 0;
+
+ // Use the actual incoming webhook's updatedAt for comparison
+ const incomingUpdatedAt = data.updatedAt
+ ? new Date(data.updatedAt).getTime()
+ : Date.now();
if (incomingUpdatedAt < existingUpdatedAt) {
console.log(`Chat ${localId} webhook skipped - local data is more recent (local: ${existingUpdatedAt}, incoming: ${incomingUpdatedAt})`);
return;
}
+ const mappedData = this.mapChatData(data, Timestamp.now());
adapter.addToLockedIds(localId);
await docRef.update(mappedData);
console.log(`Chat ${localId} updated via webhook (timestamp check passed)`);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| adapter.addToLockedIds(localId); | ||||||||||||||||||||||||||||||||||||||||||||||||
| await docRef.update(mappedData); | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.log(`Chat ${localId} updated via webhook (timestamp check passed)`); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+263
to
+287
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Consider using a Firestore transaction for atomic read-compare-write. The current implementation has a TOCTOU (time-of-check to time-of-use) race condition: the document is read, the timestamp is compared, and then updated in separate operations. Two concurrent webhooks can both pass the timestamp check before either writes. A Firestore transaction would make this atomic: Proposed fix using Firestore transaction private async updateChatIfNewer(localId: string, data: any) {
const docRef = this.db.collection("chats").doc(localId);
- const docSnapshot = await docRef.get();
-
- if (!docSnapshot.exists) {
- console.warn(`Chat document '${localId}' does not exist. Skipping update.`);
- return;
- }
-
- const existing = docSnapshot.data();
- const mappedData = this.mapChatData(data, Timestamp.now());
-
- // Compare updatedAt timestamps - accept if incoming is more recent
- const existingUpdatedAt = existing?.updatedAt?.toMillis?.() ?? 0;
- const incomingUpdatedAt = mappedData.updatedAt?.toMillis?.() ?? 0;
-
- if (incomingUpdatedAt < existingUpdatedAt) {
- console.log(`Chat ${localId} webhook skipped - local data is more recent (local: ${existingUpdatedAt}, incoming: ${incomingUpdatedAt})`);
- return;
- }
-
- adapter.addToLockedIds(localId);
- await docRef.update(mappedData);
- console.log(`Chat ${localId} updated via webhook (timestamp check passed)`);
+ const incomingUpdatedAt = data.updatedAt
+ ? new Date(data.updatedAt).getTime()
+ : Date.now();
+
+ await this.db.runTransaction(async (transaction) => {
+ const docSnapshot = await transaction.get(docRef);
+
+ if (!docSnapshot.exists) {
+ console.warn(`Chat document '${localId}' does not exist. Skipping update.`);
+ return;
+ }
+
+ const existing = docSnapshot.data();
+ const existingUpdatedAt = existing?.updatedAt?.toMillis?.() ?? 0;
+
+ if (incomingUpdatedAt < existingUpdatedAt) {
+ console.log(`Chat ${localId} webhook skipped - local data is more recent`);
+ return;
+ }
+
+ const mappedData = this.mapChatData(data, Timestamp.now());
+ transaction.update(docRef, mappedData);
+ console.log(`Chat ${localId} updated via webhook (timestamp check passed)`);
+ });
+
+ adapter.addToLockedIds(localId);
}🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| private mapDataToFirebase(tableName: string, data: any): any { | ||||||||||||||||||||||||||||||||||||||||||||||||
| const now = Timestamp.now(); | ||||||||||||||||||||||||||||||||||||||||||||||||
| console.log("MAPPING DATA TO ", tableName); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -338,6 +338,74 @@ export class GroupController { | |
| } | ||
| } | ||
|
|
||
| async updateMemberRole(req: Request, res: Response) { | ||
| try { | ||
| const { groupId, userId: targetUserId } = req.params; | ||
| const requestingUserId = (req as any).user?.id; | ||
|
|
||
| if (!requestingUserId) { | ||
| return res.status(401).json({ error: "Unauthorized" }); | ||
| } | ||
|
|
||
| const { role } = req.body; | ||
| if (!["admin", "member", "owner"].includes(role)) { | ||
| return res.status(400).json({ error: "Invalid role. Must be 'admin', 'member', or 'owner'" }); | ||
| } | ||
|
|
||
| const group = await this.groupService.getGroupById(groupId); | ||
| if (!group) { | ||
| return res.status(404).json({ error: "Group not found" }); | ||
| } | ||
|
|
||
| // Verify target user is a participant | ||
| if (!group.participants.some(p => p.id === targetUserId)) { | ||
| return res.status(400).json({ error: "User is not a participant in this group" }); | ||
| } | ||
|
|
||
| const isOwner = group.owner === requestingUserId; | ||
| const isAdmin = group.admins?.includes(requestingUserId); | ||
|
|
||
| if (!isOwner && !isAdmin) { | ||
| return res.status(403).json({ error: "Access denied" }); | ||
| } | ||
|
|
||
| const currentAdmins = group.admins || []; | ||
|
|
||
| if (role === "admin") { | ||
| // Grant admin - admins and owners can do this | ||
| if (currentAdmins.includes(targetUserId)) { | ||
| return res.status(400).json({ error: "User is already an admin" }); | ||
| } | ||
| const newAdmins = [...currentAdmins, targetUserId]; | ||
| await this.groupService.updateGroup(groupId, { admins: newAdmins } as any); | ||
| } else if (role === "member") { | ||
| // Remove admin - admins and owners can do this | ||
| if (targetUserId === group.owner) { | ||
| return res.status(400).json({ error: "Cannot demote the owner" }); | ||
| } | ||
| const newAdmins = currentAdmins.filter(id => id !== targetUserId); | ||
| await this.groupService.updateGroup(groupId, { admins: newAdmins } as any); | ||
|
Comment on lines
+381
to
+387
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Authorization behavior contradicts stated test plan. The code allows any admin to remove admin privileges from other users. However, the PR objectives state: "Admin: grant admin; verify cannot remove admin from others or transfer ownership (checked)." The commit message indicates this is intentional ("allow admins to revoke admin privileges"), but this contradicts the test plan verification. Please clarify the intended authorization model:
🔒 Option 1: Restrict to owner-only } else if (role === "member") {
// Remove admin - admins and owners can do this
+ if (!isOwner) {
+ return res.status(403).json({ error: "Only the owner can remove admin privileges" });
+ }
if (targetUserId === group.owner) {
return res.status(400).json({ error: "Cannot demote the owner" });
}🤖 Prompt for AI Agents |
||
| } else if (role === "owner") { | ||
| // Transfer ownership - only owner can do this | ||
| if (!isOwner) { | ||
| return res.status(403).json({ error: "Only the owner can transfer ownership" }); | ||
| } | ||
| // Old owner becomes admin, new owner gets added to admins if not already | ||
| let newAdmins = currentAdmins.includes(requestingUserId) ? [...currentAdmins] : [...currentAdmins, requestingUserId]; | ||
| if (!newAdmins.includes(targetUserId)) { | ||
| newAdmins.push(targetUserId); | ||
| } | ||
| await this.groupService.updateGroup(groupId, { owner: targetUserId, admins: newAdmins } as any); | ||
| } | ||
|
|
||
| const updatedGroup = await this.groupService.getGroupById(groupId); | ||
| res.json(updatedGroup); | ||
| } catch (error) { | ||
| console.error("Error updating member role:", error); | ||
| res.status(500).json({ error: "Internal server error" }); | ||
| } | ||
| } | ||
|
|
||
| async getCharterSigningStatus(req: Request, res: Response) { | ||
| try { | ||
| const { id } = req.params; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,25 @@ | ||
| "use client"; | ||
|
|
||
| import { useState, useEffect } from "react"; | ||
| import { CheckCircle, Circle, AlertTriangle } from "lucide-react"; | ||
| import { CheckCircle, Circle, AlertTriangle, MoreVertical, Shield, ShieldOff, Crown } from "lucide-react"; | ||
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | ||
| import { Badge } from "@/components/ui/badge"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { | ||
| DropdownMenu, | ||
| DropdownMenuContent, | ||
| DropdownMenuItem, | ||
| DropdownMenuTrigger, | ||
| } from "@/components/ui/dropdown-menu"; | ||
| import { apiClient } from "@/lib/apiClient"; | ||
| import { useToast } from "@/hooks/use-toast"; | ||
|
|
||
| interface CharterSigningStatusProps { | ||
| groupId: string; | ||
| charterContent: string; | ||
| currentUserId?: string; | ||
| currentUserIsAdmin?: boolean; | ||
| currentUserIsOwner?: boolean; | ||
| } | ||
|
|
||
| interface Participant { | ||
|
|
@@ -28,7 +38,7 @@ interface SigningStatus { | |
| isSigned: boolean; | ||
| } | ||
|
|
||
| export function CharterSigningStatus({ groupId, charterContent }: CharterSigningStatusProps) { | ||
| export function CharterSigningStatus({ groupId, charterContent, currentUserId, currentUserIsAdmin, currentUserIsOwner }: CharterSigningStatusProps) { | ||
| const [signingStatus, setSigningStatus] = useState<SigningStatus | null>(null); | ||
| const [loading, setLoading] = useState(true); | ||
| const { toast } = useToast(); | ||
|
|
@@ -54,6 +64,28 @@ export function CharterSigningStatus({ groupId, charterContent }: CharterSigning | |
| } | ||
| }; | ||
|
|
||
| const handleRoleChange = async (targetUserId: string, newRole: "admin" | "member" | "owner") => { | ||
| if (newRole === "owner" && !window.confirm("Are you sure you want to transfer ownership? This cannot be undone.")) { | ||
| return; | ||
| } | ||
| try { | ||
| await apiClient.patch(`/api/groups/${groupId}/members/${targetUserId}/role`, { role: newRole }); | ||
| toast({ | ||
| title: "Success", | ||
| description: newRole === "admin" ? "Admin privileges granted" : newRole === "member" ? "Admin privileges removed" : "Ownership transferred", | ||
| }); | ||
| fetchSigningStatus(); | ||
| } catch (error: any) { | ||
| toast({ | ||
| title: "Error", | ||
| description: error.response?.data?.error || "Failed to update role", | ||
| variant: "destructive", | ||
| }); | ||
| } | ||
| }; | ||
|
|
||
| const canManageRoles = currentUserIsAdmin || currentUserIsOwner; | ||
|
|
||
|
|
||
|
|
||
| if (loading) { | ||
|
|
@@ -102,22 +134,25 @@ export function CharterSigningStatus({ groupId, charterContent }: CharterSigning | |
| className="flex items-center justify-between p-3 bg-gray-50 rounded-lg" | ||
| > | ||
| <div className="flex items-center gap-3"> | ||
| {participant.hasSigned ? ( | ||
| <CheckCircle className="h-5 w-5 text-green-600" /> | ||
| ) : ( | ||
| <Circle className="h-5 w-5 text-gray-400" /> | ||
| )} | ||
| {charterContent ? ( | ||
| participant.hasSigned ? ( | ||
| <CheckCircle className="h-5 w-5 text-green-600" /> | ||
| ) : ( | ||
| <Circle className="h-5 w-5 text-gray-400" /> | ||
| ) | ||
| ) : null} | ||
| <div> | ||
| <p className="font-medium text-sm"> | ||
| {participant.name || participant.ename || 'Unknown User'} | ||
| </p> | ||
| <p className="text-xs text-gray-500"> | ||
| {participant.hasSigned ? 'Signed' : 'Not signed yet'} | ||
| </p> | ||
| {charterContent && ( | ||
| <p className="text-xs text-gray-500"> | ||
| {participant.hasSigned ? 'Signed' : 'Not signed yet'} | ||
| </p> | ||
| )} | ||
| </div> | ||
| </div> | ||
| <div className="flex items-center gap-2"> | ||
| {/* Show admin role if applicable */} | ||
| {participant.isAdmin && ( | ||
| <Badge variant="secondary" className="text-xs"> | ||
| Admin | ||
|
|
@@ -128,6 +163,35 @@ export function CharterSigningStatus({ groupId, charterContent }: CharterSigning | |
| Owner | ||
| </Badge> | ||
| )} | ||
| {canManageRoles && participant.id !== currentUserId && ( | ||
| <DropdownMenu> | ||
| <DropdownMenuTrigger asChild> | ||
| <Button variant="ghost" size="icon" className="h-7 w-7"> | ||
| <MoreVertical className="h-4 w-4" /> | ||
| </Button> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent align="end"> | ||
| {!participant.isAdmin && !participant.isOwner && ( | ||
| <DropdownMenuItem onClick={() => handleRoleChange(participant.id, "admin")}> | ||
| <Shield className="mr-2 h-4 w-4" /> | ||
| Make Admin | ||
| </DropdownMenuItem> | ||
| )} | ||
| {participant.isAdmin && !participant.isOwner && ( | ||
| <DropdownMenuItem onClick={() => handleRoleChange(participant.id, "member")}> | ||
| <ShieldOff className="mr-2 h-4 w-4" /> | ||
| Remove Admin | ||
| </DropdownMenuItem> | ||
| )} | ||
| {!participant.isOwner && currentUserIsOwner && ( | ||
| <DropdownMenuItem className="text-amber-600" onClick={() => handleRoleChange(participant.id, "owner")}> | ||
| <Crown className="mr-2 h-4 w-4" /> | ||
| Transfer Ownership | ||
| </DropdownMenuItem> | ||
| )} | ||
| </DropdownMenuContent> | ||
| </DropdownMenu> | ||
| )} | ||
|
Comment on lines
+166
to
+194
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Empty dropdown menu can render when admin views the owner. When an admin (non-owner) views this component, the dropdown will render for the owner participant, but all menu items will be hidden:
This results in clicking the three-dot button and seeing an empty menu. 🐛 Proposed fix: only render dropdown if actions are available- {canManageRoles && participant.id !== currentUserId && (
+ {canManageRoles && participant.id !== currentUserId && (
+ // Only show dropdown if there are available actions
+ (!participant.isAdmin && !participant.isOwner) || // can make admin
+ (participant.isAdmin && !participant.isOwner) || // can remove admin
+ (!participant.isOwner && currentUserIsOwner) // can transfer ownership
+ ) && (
<DropdownMenu>Or extract to a helper: const hasAvailableActions = (participant: Participant) => {
if (participant.id === currentUserId) return false;
if (!canManageRoles) return false;
// Can make admin
if (!participant.isAdmin && !participant.isOwner) return true;
// Can remove admin (admin but not owner)
if (participant.isAdmin && !participant.isOwner) return true;
// Can transfer ownership (current user is owner, target is not owner)
if (!participant.isOwner && currentUserIsOwner) return true;
return false;
};🤖 Prompt for AI Agents |
||
| </div> | ||
| </div> | ||
| ))} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Race condition window for concurrent chat webhooks.
By skipping the lock check for chats entirely (line 106), multiple concurrent webhooks for the same chat ID can all proceed to
updateChatIfNewersimultaneously. The lock is only acquired at line 284 (after the timestamp check), leaving a race window where:Since the timestamp comparison uses
Timestamp.now()(see related comment onupdateChatIfNewer), this race is more likely to cause issues. Consider acquiring the lock earlier in the chat path, or using a Firestore transaction for atomic read-compare-write.🤖 Prompt for AI Agents