Skip to content

Commit c2ba24a

Browse files
authored
feat: admin/owner role management on group page (#912)
* feat: add endpoint for managing member admin/owner roles Add PATCH /api/groups/:groupId/members/:userId/role endpoint that allows admins to grant admin privileges and owners to revoke admin or transfer ownership. Uses existing GroupService.updateGroup for persistence. * feat: add role management UI to group member list Add dropdown menu per member in the signing status sidebar allowing admins to grant admin privileges and owners to revoke admin or transfer ownership. Controls are only visible to users with appropriate permissions. * fix: allow admins to remove admin, show members without charter - Remove admin button now visible to all admins, not just owners - Backend allows admins to revoke admin privileges from other admins - Members list renders on group page even when no charter exists - Hide signing status indicators when no charter is present * fix: replace lock-based webhook rejection with timestamp check for chats Instead of blindly rejecting chat webhooks when the ID is locked, compare updatedAt timestamps and accept the update if the incoming data is more recent than what we have locally.
1 parent aad4b07 commit c2ba24a

File tree

5 files changed

+203
-35
lines changed

5 files changed

+203
-35
lines changed

platforms/blabsy/api/src/controllers/WebhookController.ts

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -94,40 +94,47 @@ export class WebhookController {
9494
axios.post(new URL("blabsy", process.env.ANCHR_URL).toString(), req.body)
9595
}
9696

97-
// Early duplicate check
98-
if (adapter.lockedIds.includes(id)) {
97+
const mapping = Object.values(adapter.mapping).find(
98+
(m) => m.schemaId === schemaId,
99+
);
100+
if (!mapping) throw new Error();
101+
const tableName = mapping.tableName + "s";
102+
103+
// For chats, skip the lock check and use timestamp comparison instead
104+
const isChatData = tableName === "chats";
105+
106+
if (!isChatData && adapter.lockedIds.includes(id)) {
99107
console.log(`Webhook skipped - ID ${id} already locked`);
100108
return res.status(200).json({ success: true, skipped: true });
101109
}
102110

103111
console.log(`Processing webhook for ID: ${id}`);
104-
105-
// Lock the global ID immediately to prevent duplicates
106-
adapter.addToLockedIds(id);
107112

108-
const mapping = Object.values(adapter.mapping).find(
109-
(m) => m.schemaId === schemaId,
110-
);
111-
if (!mapping) throw new Error();
112-
const tableName = mapping.tableName + "s";
113+
// Lock the global ID immediately to prevent duplicates (non-chat)
114+
if (!isChatData) {
115+
adapter.addToLockedIds(id);
116+
}
113117

114118
const local = await adapter.fromGlobal({ data, mapping });
115119

116-
console.log("Webhook data received:", {
117-
globalId: id,
118-
tableName,
120+
console.log("Webhook data received:", {
121+
globalId: id,
122+
tableName,
119123
hasEname: !!local.data.ename,
120-
ename: local.data.ename
124+
ename: local.data.ename
121125
});
122-
126+
123127
// Get the local ID from the mapping database
124128
const localId = await adapter.mappingDb.getLocalId(id);
125129

126130
if (localId) {
127131
console.log(`LOCAL, updating - ID: ${id}, LocalID: ${localId}`);
128-
// Lock local ID early to prevent duplicate processing
129-
adapter.addToLockedIds(localId);
130-
await this.updateRecord(tableName, localId, local.data);
132+
if (isChatData) {
133+
await this.updateChatIfNewer(localId, local.data);
134+
} else {
135+
adapter.addToLockedIds(localId);
136+
await this.updateRecord(tableName, localId, local.data);
137+
}
131138
} else {
132139
console.log(`NOT LOCAL, creating - ID: ${id}`);
133140
await this.createRecord(tableName, local.data, req.body.id);
@@ -252,6 +259,33 @@ export class WebhookController {
252259
const mappedData = await this.mapDataToFirebase(tableName, data);
253260
await docRef.update(mappedData);
254261
}
262+
263+
private async updateChatIfNewer(localId: string, data: any) {
264+
const docRef = this.db.collection("chats").doc(localId);
265+
const docSnapshot = await docRef.get();
266+
267+
if (!docSnapshot.exists) {
268+
console.warn(`Chat document '${localId}' does not exist. Skipping update.`);
269+
return;
270+
}
271+
272+
const existing = docSnapshot.data();
273+
const mappedData = this.mapChatData(data, Timestamp.now());
274+
275+
// Compare updatedAt timestamps - accept if incoming is more recent
276+
const existingUpdatedAt = existing?.updatedAt?.toMillis?.() ?? 0;
277+
const incomingUpdatedAt = mappedData.updatedAt?.toMillis?.() ?? 0;
278+
279+
if (incomingUpdatedAt < existingUpdatedAt) {
280+
console.log(`Chat ${localId} webhook skipped - local data is more recent (local: ${existingUpdatedAt}, incoming: ${incomingUpdatedAt})`);
281+
return;
282+
}
283+
284+
adapter.addToLockedIds(localId);
285+
await docRef.update(mappedData);
286+
console.log(`Chat ${localId} updated via webhook (timestamp check passed)`);
287+
}
288+
255289
private mapDataToFirebase(tableName: string, data: any): any {
256290
const now = Timestamp.now();
257291
console.log("MAPPING DATA TO ", tableName);

platforms/group-charter-manager/api/src/controllers/GroupController.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,74 @@ export class GroupController {
338338
}
339339
}
340340

341+
async updateMemberRole(req: Request, res: Response) {
342+
try {
343+
const { groupId, userId: targetUserId } = req.params;
344+
const requestingUserId = (req as any).user?.id;
345+
346+
if (!requestingUserId) {
347+
return res.status(401).json({ error: "Unauthorized" });
348+
}
349+
350+
const { role } = req.body;
351+
if (!["admin", "member", "owner"].includes(role)) {
352+
return res.status(400).json({ error: "Invalid role. Must be 'admin', 'member', or 'owner'" });
353+
}
354+
355+
const group = await this.groupService.getGroupById(groupId);
356+
if (!group) {
357+
return res.status(404).json({ error: "Group not found" });
358+
}
359+
360+
// Verify target user is a participant
361+
if (!group.participants.some(p => p.id === targetUserId)) {
362+
return res.status(400).json({ error: "User is not a participant in this group" });
363+
}
364+
365+
const isOwner = group.owner === requestingUserId;
366+
const isAdmin = group.admins?.includes(requestingUserId);
367+
368+
if (!isOwner && !isAdmin) {
369+
return res.status(403).json({ error: "Access denied" });
370+
}
371+
372+
const currentAdmins = group.admins || [];
373+
374+
if (role === "admin") {
375+
// Grant admin - admins and owners can do this
376+
if (currentAdmins.includes(targetUserId)) {
377+
return res.status(400).json({ error: "User is already an admin" });
378+
}
379+
const newAdmins = [...currentAdmins, targetUserId];
380+
await this.groupService.updateGroup(groupId, { admins: newAdmins } as any);
381+
} else if (role === "member") {
382+
// Remove admin - admins and owners can do this
383+
if (targetUserId === group.owner) {
384+
return res.status(400).json({ error: "Cannot demote the owner" });
385+
}
386+
const newAdmins = currentAdmins.filter(id => id !== targetUserId);
387+
await this.groupService.updateGroup(groupId, { admins: newAdmins } as any);
388+
} else if (role === "owner") {
389+
// Transfer ownership - only owner can do this
390+
if (!isOwner) {
391+
return res.status(403).json({ error: "Only the owner can transfer ownership" });
392+
}
393+
// Old owner becomes admin, new owner gets added to admins if not already
394+
let newAdmins = currentAdmins.includes(requestingUserId) ? [...currentAdmins] : [...currentAdmins, requestingUserId];
395+
if (!newAdmins.includes(targetUserId)) {
396+
newAdmins.push(targetUserId);
397+
}
398+
await this.groupService.updateGroup(groupId, { owner: targetUserId, admins: newAdmins } as any);
399+
}
400+
401+
const updatedGroup = await this.groupService.getGroupById(groupId);
402+
res.json(updatedGroup);
403+
} catch (error) {
404+
console.error("Error updating member role:", error);
405+
res.status(500).json({ error: "Internal server error" });
406+
}
407+
}
408+
341409
async getCharterSigningStatus(req: Request, res: Response) {
342410
try {
343411
const { id } = req.params;

platforms/group-charter-manager/api/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ app.get("/api/signing/sessions/:sessionId", authGuard, (req, res) => {
157157
app.get("/api/groups/:groupId", authGuard, groupController.getGroup.bind(groupController));
158158
app.post("/api/groups/:groupId/participants", authGuard, groupController.addParticipants.bind(groupController));
159159
app.delete("/api/groups/:groupId/participants/:userId", authGuard, groupController.removeParticipant.bind(groupController));
160+
app.patch("/api/groups/:groupId/members/:userId/role", authGuard, groupController.updateMemberRole.bind(groupController));
160161

161162
// Start server
162163
app.listen(port, () => {

platforms/group-charter-manager/client/src/app/charter/[id]/page.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -419,12 +419,13 @@ export default function CharterDetail({
419419
)}
420420

421421
{/* Charter Signing Status */}
422-
{group.charter && (
423-
<CharterSigningStatus
424-
groupId={group.id}
425-
charterContent={group.charter}
426-
/>
427-
)}
422+
<CharterSigningStatus
423+
groupId={group.id}
424+
charterContent={group.charter || ""}
425+
currentUserId={user?.id}
426+
currentUserIsAdmin={group.admins?.includes(user?.id || '') || false}
427+
currentUserIsOwner={group.owner === user?.id}
428+
/>
428429
</div>
429430
</div>
430431

platforms/group-charter-manager/client/src/components/charter-signing-status.tsx

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
"use client";
22

33
import { useState, useEffect } from "react";
4-
import { CheckCircle, Circle, AlertTriangle } from "lucide-react";
4+
import { CheckCircle, Circle, AlertTriangle, MoreVertical, Shield, ShieldOff, Crown } from "lucide-react";
55
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
66
import { Badge } from "@/components/ui/badge";
7+
import { Button } from "@/components/ui/button";
8+
import {
9+
DropdownMenu,
10+
DropdownMenuContent,
11+
DropdownMenuItem,
12+
DropdownMenuTrigger,
13+
} from "@/components/ui/dropdown-menu";
714
import { apiClient } from "@/lib/apiClient";
815
import { useToast } from "@/hooks/use-toast";
916

1017
interface CharterSigningStatusProps {
1118
groupId: string;
1219
charterContent: string;
20+
currentUserId?: string;
21+
currentUserIsAdmin?: boolean;
22+
currentUserIsOwner?: boolean;
1323
}
1424

1525
interface Participant {
@@ -28,7 +38,7 @@ interface SigningStatus {
2838
isSigned: boolean;
2939
}
3040

31-
export function CharterSigningStatus({ groupId, charterContent }: CharterSigningStatusProps) {
41+
export function CharterSigningStatus({ groupId, charterContent, currentUserId, currentUserIsAdmin, currentUserIsOwner }: CharterSigningStatusProps) {
3242
const [signingStatus, setSigningStatus] = useState<SigningStatus | null>(null);
3343
const [loading, setLoading] = useState(true);
3444
const { toast } = useToast();
@@ -54,6 +64,28 @@ export function CharterSigningStatus({ groupId, charterContent }: CharterSigning
5464
}
5565
};
5666

67+
const handleRoleChange = async (targetUserId: string, newRole: "admin" | "member" | "owner") => {
68+
if (newRole === "owner" && !window.confirm("Are you sure you want to transfer ownership? This cannot be undone.")) {
69+
return;
70+
}
71+
try {
72+
await apiClient.patch(`/api/groups/${groupId}/members/${targetUserId}/role`, { role: newRole });
73+
toast({
74+
title: "Success",
75+
description: newRole === "admin" ? "Admin privileges granted" : newRole === "member" ? "Admin privileges removed" : "Ownership transferred",
76+
});
77+
fetchSigningStatus();
78+
} catch (error: any) {
79+
toast({
80+
title: "Error",
81+
description: error.response?.data?.error || "Failed to update role",
82+
variant: "destructive",
83+
});
84+
}
85+
};
86+
87+
const canManageRoles = currentUserIsAdmin || currentUserIsOwner;
88+
5789

5890

5991
if (loading) {
@@ -102,22 +134,25 @@ export function CharterSigningStatus({ groupId, charterContent }: CharterSigning
102134
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
103135
>
104136
<div className="flex items-center gap-3">
105-
{participant.hasSigned ? (
106-
<CheckCircle className="h-5 w-5 text-green-600" />
107-
) : (
108-
<Circle className="h-5 w-5 text-gray-400" />
109-
)}
137+
{charterContent ? (
138+
participant.hasSigned ? (
139+
<CheckCircle className="h-5 w-5 text-green-600" />
140+
) : (
141+
<Circle className="h-5 w-5 text-gray-400" />
142+
)
143+
) : null}
110144
<div>
111145
<p className="font-medium text-sm">
112146
{participant.name || participant.ename || 'Unknown User'}
113147
</p>
114-
<p className="text-xs text-gray-500">
115-
{participant.hasSigned ? 'Signed' : 'Not signed yet'}
116-
</p>
148+
{charterContent && (
149+
<p className="text-xs text-gray-500">
150+
{participant.hasSigned ? 'Signed' : 'Not signed yet'}
151+
</p>
152+
)}
117153
</div>
118154
</div>
119155
<div className="flex items-center gap-2">
120-
{/* Show admin role if applicable */}
121156
{participant.isAdmin && (
122157
<Badge variant="secondary" className="text-xs">
123158
Admin
@@ -128,6 +163,35 @@ export function CharterSigningStatus({ groupId, charterContent }: CharterSigning
128163
Owner
129164
</Badge>
130165
)}
166+
{canManageRoles && participant.id !== currentUserId && (
167+
<DropdownMenu>
168+
<DropdownMenuTrigger asChild>
169+
<Button variant="ghost" size="icon" className="h-7 w-7">
170+
<MoreVertical className="h-4 w-4" />
171+
</Button>
172+
</DropdownMenuTrigger>
173+
<DropdownMenuContent align="end">
174+
{!participant.isAdmin && !participant.isOwner && (
175+
<DropdownMenuItem onClick={() => handleRoleChange(participant.id, "admin")}>
176+
<Shield className="mr-2 h-4 w-4" />
177+
Make Admin
178+
</DropdownMenuItem>
179+
)}
180+
{participant.isAdmin && !participant.isOwner && (
181+
<DropdownMenuItem onClick={() => handleRoleChange(participant.id, "member")}>
182+
<ShieldOff className="mr-2 h-4 w-4" />
183+
Remove Admin
184+
</DropdownMenuItem>
185+
)}
186+
{!participant.isOwner && currentUserIsOwner && (
187+
<DropdownMenuItem className="text-amber-600" onClick={() => handleRoleChange(participant.id, "owner")}>
188+
<Crown className="mr-2 h-4 w-4" />
189+
Transfer Ownership
190+
</DropdownMenuItem>
191+
)}
192+
</DropdownMenuContent>
193+
</DropdownMenu>
194+
)}
131195
</div>
132196
</div>
133197
))}

0 commit comments

Comments
 (0)