From 04aa2ffd1333888e0d1551addb51320f5548a13f Mon Sep 17 00:00:00 2001 From: Ryan Brackney Date: Mon, 13 Apr 2026 16:26:45 -0700 Subject: [PATCH 1/5] feat: add change member role in channel sidebar The members sidebar only supported adding members with a role at invite time and removing them. There was no way to change an existing member's role without removing and re-adding them. Added a "Change role" submenu to the member actions dropdown that lets owners/admins change any other member's role (admin, member, guest, bot). The feature reuses the existing add_member upsert path which already handles role updates, so no database or relay changes were needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/src/commands/channels.rs | 17 ++++ desktop/src-tauri/src/lib.rs | 1 + desktop/src/features/channels/hooks.ts | 20 +++++ .../features/channels/ui/MembersSidebar.tsx | 6 ++ .../channels/ui/MembersSidebarMemberCard.tsx | 78 ++++++++++++++++--- desktop/src/shared/api/tauri.ts | 8 ++ 6 files changed, 118 insertions(+), 12 deletions(-) diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 0a2ab77bee5..790c24d0a31 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -195,6 +195,23 @@ pub async fn remove_channel_member( Ok(()) } +#[tauri::command] +pub async fn change_channel_member_role( + channel_id: String, + pubkey: String, + role: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let uuid = parse_channel_uuid(&channel_id)?; + let role_str = match role.as_str() { + "admin" | "member" | "guest" | "bot" => role.as_str(), + other => return Err(format!("invalid role: {other}")), + }; + let builder = events::build_add_member(uuid, &pubkey, Some(role_str))?; + submit_event(builder, &state).await?; + Ok(()) +} + #[tauri::command] pub async fn join_channel(channel_id: String, state: State<'_, AppState>) -> Result<(), String> { let uuid = parse_channel_uuid(&channel_id)?; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f26ab2d81e4..095824c2d48 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -437,6 +437,7 @@ pub fn run() { delete_channel, add_channel_members, remove_channel_member, + change_channel_member_role, join_channel, leave_channel, get_canvas, diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 20d87420383..0e7062d76e4 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { addChannelMembers, archiveChannel, + changeChannelMemberRole, createChannel, deleteChannel, getCanvas, @@ -423,6 +424,25 @@ export function useRemoveChannelMemberMutation(channelId: string | null) { }); } +export function useChangeChannelMemberRoleMutation( + channelId: string | null, +) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => { + if (!channelId) { + throw new Error("No channel selected."); + } + + await changeChannelMemberRole(channelId, pubkey, role); + }, + onSettled: async () => { + await invalidateChannelState(queryClient, channelId); + }, + }); +} + export function useJoinChannelMutation(channelId: string | null) { const queryClient = useQueryClient(); diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 06c41953024..c0ead7d1657 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { useAddChannelMembersMutation, + useChangeChannelMemberRoleMutation, useChannelMembersQuery, } from "@/features/channels/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; @@ -37,6 +38,7 @@ export function MembersSidebar({ const channelId = channel?.id ?? null; const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(channelId); + const changeRoleMutation = useChangeChannelMemberRoleMutation(channelId); const rawMembers = membersQuery.data ?? []; const { people, bots, isBot, isMyBot, managedAgentsQuery } = @@ -127,6 +129,7 @@ export function MembersSidebar({ function renderMemberCard(member: ChannelMember, memberIsBot: boolean) { return ( { + void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); + }} onManagedAgentAction={(agent) => { void handleAgentLifecycleAction(agent); }} diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 8cac47e4893..0bb9c71b849 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -1,4 +1,11 @@ -import { Ellipsis, Play, RotateCcw, Square, Trash2 } from "lucide-react"; +import { + Ellipsis, + Play, + RotateCcw, + Shield, + Square, + Trash2, +} from "lucide-react"; import { getManagedAgentPrimaryActionLabel, @@ -17,10 +24,14 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; type MembersSidebarMemberCardProps = { + canChangeRole: boolean; canRemoveMember: boolean; isActionPending: boolean; isArchived: boolean; @@ -28,6 +39,7 @@ type MembersSidebarMemberCardProps = { member: ChannelMember; memberIsBot: boolean; memberLabel: string; + onChangeRole: (member: ChannelMember, role: string) => void; onManagedAgentAction: (agent: ManagedAgent) => void; onRemoveMember: (member: ChannelMember) => void; presenceStatus?: PresenceStatus | null; @@ -56,6 +68,7 @@ function formatManagedAgentStatus(agent: ManagedAgent) { } export function MembersSidebarMemberCard({ + canChangeRole, canRemoveMember, isActionPending, isArchived, @@ -63,6 +76,7 @@ export function MembersSidebarMemberCard({ member, memberIsBot, memberLabel, + onChangeRole, onManagedAgentAction, onRemoveMember, presenceStatus, @@ -72,7 +86,7 @@ export function MembersSidebarMemberCard({ const disabled = isActionPending || isArchived; const hasActions = memberIsBot ? Boolean(managedAgent) || canRemoveMember - : canRemoveMember; + : canRemoveMember || canChangeRole; return (
{hasActions ? ( @@ -131,20 +147,26 @@ export function MembersSidebarMemberCard({ ); } +const ASSIGNABLE_ROLES = ["admin", "member", "guest", "bot"] as const; + function MemberActionsMenu({ + canChangeRole, canRemoveMember, disabled, managedAgent, member, memberIsBot, + onChangeRole, onManagedAgentAction, onRemoveMember, }: { + canChangeRole: boolean; canRemoveMember: boolean; disabled: boolean; managedAgent?: ManagedAgent; member: ChannelMember; memberIsBot: boolean; + onChangeRole: (member: ChannelMember, role: string) => void; onManagedAgentAction: (agent: ManagedAgent) => void; onRemoveMember: (member: ChannelMember) => void; }) { @@ -173,19 +195,51 @@ function MemberActionsMenu({ {getManagedAgentActionIcon(managedAgent)} {getManagedAgentPrimaryActionLabel(managedAgent)} - {canRemoveMember ? : null} + {canRemoveMember || canChangeRole ? ( + + ) : null} ) : null} + {canChangeRole && member.role !== "owner" ? ( + + + + Change role + + + {ASSIGNABLE_ROLES.map((role) => ( + onChangeRole(member, role)} + > + {role[0]?.toUpperCase()} + {role.slice(1)} + {member.role === role ? " (current)" : ""} + + ))} + + + ) : null} {canRemoveMember ? ( - onRemoveMember(member)} - > - - Remove from channel - + <> + {canChangeRole && member.role !== "owner" ? ( + + ) : null} + onRemoveMember(member)} + > + + Remove from channel + + ) : null} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index b105c090eaf..daa55e4e2c9 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -615,6 +615,14 @@ export async function removeChannelMember( await invokeTauri("remove_channel_member", { channelId, pubkey }); } +export async function changeChannelMemberRole( + channelId: string, + pubkey: string, + role: string, +): Promise { + await invokeTauri("change_channel_member_role", { channelId, pubkey, role }); +} + export async function joinChannel(channelId: string): Promise { await invokeTauri("join_channel", { channelId }); } From 96993f82f15865eda610b8a6d47d0639c85084ae Mon Sep 17 00:00:00 2001 From: Ryan Brackney Date: Mon, 13 Apr 2026 16:43:39 -0700 Subject: [PATCH 2/5] fix: constrain role transitions and add error handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses code review feedback: - Separate assignable roles by member type: people get admin/member/guest, bots get bot/guest. Prevents nonsensical people↔bot transitions. - Block owner assignment via the change role command (requires dedicated transfer-ownership flow). - Surface mutation pending state to disable the menu during role changes. - Display role change errors in the sidebar error area. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src-tauri/src/commands/channels.rs | 3 +++ .../features/channels/ui/MembersSidebar.tsx | 9 +++++--- .../channels/ui/MembersSidebarMemberCard.tsx | 21 ++++++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 790c24d0a31..2844fd1f565 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -203,8 +203,11 @@ pub async fn change_channel_member_role( state: State<'_, AppState>, ) -> Result<(), String> { let uuid = parse_channel_uuid(&channel_id)?; + // Only allow permission-tier roles for humans and bot/guest for bots. + // Owner changes require a dedicated transfer-ownership flow. let role_str = match role.as_str() { "admin" | "member" | "guest" | "bot" => role.as_str(), + "owner" => return Err("cannot assign owner role — use transfer ownership".into()), other => return Err(format!("invalid role: {other}")), }; let builder = events::build_add_member(uuid, &pubkey, Some(role_str))?; diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index c0ead7d1657..3eacdbec89a 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -39,6 +39,9 @@ export function MembersSidebar({ const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(channelId); const changeRoleMutation = useChangeChannelMemberRoleMutation(channelId); + const changeRoleError = changeRoleMutation.error instanceof Error + ? changeRoleMutation.error.message + : null; const rawMembers = membersQuery.data ?? []; const { people, bots, isBot, isMyBot, managedAgentsQuery } = @@ -131,7 +134,7 @@ export function MembersSidebar({ ) : null} - {actionErrorMessage ? ( + {actionErrorMessage || changeRoleError ? (

- {actionErrorMessage} + {actionErrorMessage ?? changeRoleError}

) : null}
diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 0bb9c71b849..977883a11c3 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -147,7 +147,12 @@ export function MembersSidebarMemberCard({ ); } -const ASSIGNABLE_ROLES = ["admin", "member", "guest", "bot"] as const; +const PEOPLE_ROLES = ["admin", "member", "guest"] as const; +const BOT_ROLES = ["bot", "guest"] as const; + +function getAssignableRoles(memberIsBot: boolean) { + return memberIsBot ? BOT_ROLES : PEOPLE_ROLES; +} function MemberActionsMenu({ canChangeRole, @@ -170,6 +175,10 @@ function MemberActionsMenu({ onManagedAgentAction: (agent: ManagedAgent) => void; onRemoveMember: (member: ChannelMember) => void; }) { + const assignableRoles = getAssignableRoles(memberIsBot); + const showChangeRole = + canChangeRole && member.role !== "owner" && assignableRoles.length > 1; + return ( @@ -195,12 +204,12 @@ function MemberActionsMenu({ {getManagedAgentActionIcon(managedAgent)} {getManagedAgentPrimaryActionLabel(managedAgent)} - {canRemoveMember || canChangeRole ? ( + {canRemoveMember || showChangeRole ? ( ) : null} ) : null} - {canChangeRole && member.role !== "owner" ? ( + {showChangeRole ? ( - {ASSIGNABLE_ROLES.map((role) => ( + {assignableRoles.map((role) => ( - {canChangeRole && member.role !== "owner" ? ( - - ) : null} + {showChangeRole ? : null} Date: Mon, 13 Apr 2026 16:44:30 -0700 Subject: [PATCH 3/5] fix: hide role change for bots entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bots should always be "bot" — changing a bot to guest makes it read-only and unable to respond, which is a broken agent. Role changes are now only available for human members (admin/member/guest). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../features/channels/ui/MembersSidebarMemberCard.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index 977883a11c3..8b5625ea077 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -148,11 +148,6 @@ export function MembersSidebarMemberCard({ } const PEOPLE_ROLES = ["admin", "member", "guest"] as const; -const BOT_ROLES = ["bot", "guest"] as const; - -function getAssignableRoles(memberIsBot: boolean) { - return memberIsBot ? BOT_ROLES : PEOPLE_ROLES; -} function MemberActionsMenu({ canChangeRole, @@ -175,9 +170,8 @@ function MemberActionsMenu({ onManagedAgentAction: (agent: ManagedAgent) => void; onRemoveMember: (member: ChannelMember) => void; }) { - const assignableRoles = getAssignableRoles(memberIsBot); const showChangeRole = - canChangeRole && member.role !== "owner" && assignableRoles.length > 1; + canChangeRole && !memberIsBot && member.role !== "owner"; return ( @@ -219,7 +213,7 @@ function MemberActionsMenu({ Change role - {assignableRoles.map((role) => ( + {PEOPLE_ROLES.map((role) => ( Date: Mon, 13 Apr 2026 19:55:12 -0700 Subject: [PATCH 4/5] style: fix biome lint formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/features/channels/hooks.ts | 4 +--- desktop/src/features/channels/ui/MembersSidebar.tsx | 7 ++++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 0e7062d76e4..a4340955b10 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -424,9 +424,7 @@ export function useRemoveChannelMemberMutation(channelId: string | null) { }); } -export function useChangeChannelMemberRoleMutation( - channelId: string | null, -) { +export function useChangeChannelMemberRoleMutation(channelId: string | null) { const queryClient = useQueryClient(); return useMutation({ diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 3eacdbec89a..768e34ea24c 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -39,9 +39,10 @@ export function MembersSidebar({ const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(channelId); const changeRoleMutation = useChangeChannelMemberRoleMutation(channelId); - const changeRoleError = changeRoleMutation.error instanceof Error - ? changeRoleMutation.error.message - : null; + const changeRoleError = + changeRoleMutation.error instanceof Error + ? changeRoleMutation.error.message + : null; const rawMembers = membersQuery.data ?? []; const { people, bots, isBot, isMyBot, managedAgentsQuery } = From 38b8157d09056db140c8b041540a2b3157777344 Mon Sep 17 00:00:00 2001 From: Ryan Brackney Date: Mon, 13 Apr 2026 19:58:34 -0700 Subject: [PATCH 5/5] fix: move role mutation inline to stay under file size limit hooks.ts was 554 lines (limit 550). Moved the changeChannelMemberRole mutation inline into MembersSidebar.tsx where it's the only consumer. Co-Authored-By: Claude Opus 4.6 (1M context) --- desktop/src/features/channels/hooks.ts | 18 ------------------ .../features/channels/ui/MembersSidebar.tsx | 16 ++++++++++++++-- 2 files changed, 14 insertions(+), 20 deletions(-) diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index a4340955b10..20d87420383 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -4,7 +4,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { addChannelMembers, archiveChannel, - changeChannelMemberRole, createChannel, deleteChannel, getCanvas, @@ -424,23 +423,6 @@ export function useRemoveChannelMemberMutation(channelId: string | null) { }); } -export function useChangeChannelMemberRoleMutation(channelId: string | null) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => { - if (!channelId) { - throw new Error("No channel selected."); - } - - await changeChannelMemberRole(channelId, pubkey, role); - }, - onSettled: async () => { - await invalidateChannelState(queryClient, channelId); - }, - }); -} - export function useJoinChannelMutation(channelId: string | null) { const queryClient = useQueryClient(); diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 768e34ea24c..a1255f127ac 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -1,13 +1,14 @@ import * as React from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAddChannelMembersMutation, - useChangeChannelMemberRoleMutation, useChannelMembersQuery, } from "@/features/channels/hooks"; import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembers"; import { formatMemberName } from "@/features/channels/lib/memberUtils"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; +import { changeChannelMemberRole } from "@/shared/api/tauri"; import type { Channel, ChannelMember } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { @@ -36,9 +37,20 @@ export function MembersSidebar({ onOpenChange, }: MembersSidebarProps) { const channelId = channel?.id ?? null; + const queryClient = useQueryClient(); const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(channelId); - const changeRoleMutation = useChangeChannelMemberRoleMutation(channelId); + const changeRoleMutation = useMutation({ + mutationFn: async ({ pubkey, role }: { pubkey: string; role: string }) => { + if (!channelId) throw new Error("No channel selected."); + await changeChannelMemberRole(channelId, pubkey, role); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ + queryKey: ["channels", channelId], + }); + }, + }); const changeRoleError = changeRoleMutation.error instanceof Error ? changeRoleMutation.error.message