fix(desktop): let channel members add members and bots without admin (#815)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-02 09:55:09 -07:00
committed by GitHub
co-authored by Brain
parent a25ca5d1bf
commit 41a3fc1589
4 changed files with 80 additions and 2 deletions
@@ -35,12 +35,14 @@ function formatSearchUserSecondary(user: UserSearchResult) {
} }
export function ChannelMemberInviteCard({ export function ChannelMemberInviteCard({
canAssignElevatedRoles,
existingMembers, existingMembers,
isPending, isPending,
onSubmit, onSubmit,
open, open,
requestErrorMessage, requestErrorMessage,
}: { }: {
canAssignElevatedRoles: boolean;
existingMembers: ChannelMember[]; existingMembers: ChannelMember[];
isPending: boolean; isPending: boolean;
onSubmit: (input: { onSubmit: (input: {
@@ -63,6 +65,28 @@ export function ChannelMemberInviteCard({
AddChannelMembersResult["errors"] AddChannelMembersResult["errors"]
>([]); >([]);
// Only owners/admins may grant the elevated "admin" role — the relay rejects
// it for everyone else. Hide it from the dropdown so we never offer a role
// the server will refuse. "member", "guest", and "bot" are non-elevated and
// any channel member may grant them.
const availableRoles = React.useMemo<
Exclude<ChannelMember["role"], "owner">[]
>(
() =>
canAssignElevatedRoles
? ["member", "admin", "guest", "bot"]
: ["member", "guest", "bot"],
[canAssignElevatedRoles],
);
// Guard against a stale elevated selection if the caller's permissions change
// while the card is mounted (e.g. the member gets demoted).
React.useEffect(() => {
if (!availableRoles.includes(inviteRole)) {
setInviteRole("member");
}
}, [availableRoles, inviteRole]);
const deferredInviteQuery = React.useDeferredValue(inviteQuery.trim()); const deferredInviteQuery = React.useDeferredValue(inviteQuery.trim());
const selectedInviteePubkeys = React.useMemo( const selectedInviteePubkeys = React.useMemo(
() => () =>
@@ -329,7 +353,7 @@ export function ChannelMemberInviteCard({
} }
value={inviteRole} value={inviteRole}
> >
{["member", "admin", "guest", "bot"].map((role) => ( {availableRoles.map((role) => (
<option key={role} value={role}> <option key={role} value={role}>
{role} {role}
</option> </option>
@@ -228,9 +228,10 @@ export function MembersSidebar({
</SheetHeader> </SheetHeader>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6"> <div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
{(canManageMembers || channel.visibility === "open") && {(selfMember !== null || channel.visibility === "open") &&
channel.channelType !== "dm" ? ( channel.channelType !== "dm" ? (
<ChannelMemberInviteCard <ChannelMemberInviteCard
canAssignElevatedRoles={canManageMembers}
existingMembers={rawMembers} existingMembers={rawMembers}
isPending={addMembersMutation.isPending} isPending={addMembersMutation.isPending}
onSubmit={(input) => addMembersMutation.mutateAsync(input)} onSubmit={(input) => addMembersMutation.mutateAsync(input)}
+25
View File
@@ -1055,6 +1055,31 @@ const mockChannels: MockChannel[] = [
createMockMember(MOCK_IDENTITY_PUBKEY, "guest", 700), createMockMember(MOCK_IDENTITY_PUBKEY, "guest", 700),
], ],
}), }),
createMockChannel({
id: "3c2d9f0a-1b44-5e77-9a21-6f8b0c4d2e91",
name: "secret-projects",
channel_type: "stream",
visibility: "private",
description: "Private project room",
topic: "Skunkworks",
purpose: "Coordinate confidential project work.",
last_message_at: null,
archived_at: null,
created_by: ALICE_PUBKEY,
topic_set_by: ALICE_PUBKEY,
topic_set_at: isoMinutesAgo(120),
purpose_set_by: ALICE_PUBKEY,
purpose_set_at: isoMinutesAgo(130),
topic_required: false,
max_members: null,
nip29_group_id: null,
created_minutes_ago: 600,
updated_minutes_ago: 120,
members: [
createMockMember(ALICE_PUBKEY, "owner", 600),
createMockMember(MOCK_IDENTITY_PUBKEY, "member", 540),
],
}),
createMockChannel({ createMockChannel({
id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8", id: "f48efb06-0c93-5025-aac9-2e646bb6bfa8",
name: "alice-tyler", name: "alice-tyler",
+28
View File
@@ -872,6 +872,34 @@ test("open-channel members can add agents from the header", async ({
await expect(page.getByTestId("add-channel-bot-dialog-footer")).toBeVisible(); await expect(page.getByTestId("add-channel-bot-dialog-footer")).toBeVisible();
}); });
test("private-channel members can add members and bots without admin", async ({
page,
}) => {
await page.goto("/");
// secret-projects is a private (non-DM) channel where the current user is a
// plain member, not owner/admin. They should still be able to add members
// and bots — only granting elevated roles is reserved for owners/admins.
await openMembersSidebar(page, "secret-projects");
// The invite card is shown to any member, not just owners/admins.
await expect(
page.getByTestId("channel-management-search-users"),
).toBeVisible();
await expect(
page.getByTestId("channel-management-add-members"),
).toBeVisible();
// The role dropdown hides the elevated "admin" option for non-admins — the
// relay rejects it anyway — while keeping the non-elevated roles a member
// may grant (member, guest, bot).
const roleSelect = page.getByTestId("channel-management-add-role");
await expect(roleSelect.locator("option")).toHaveText([
"member",
"guest",
"bot",
]);
});
test("removing a channel-scoped agent preserves the managed agent record", async ({ test("removing a channel-scoped agent preserves the managed agent record", async ({
page, page,
}) => { }) => {