diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 0a2ab77bee5..2844fd1f565 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -195,6 +195,26 @@ 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)?; + // 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))?; + 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/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 06c41953024..a1255f127ac 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAddChannelMembersMutation, useChannelMembersQuery, @@ -7,6 +8,7 @@ import { useClassifiedMembers } from "@/features/channels/lib/useClassifiedMembe 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 { @@ -35,8 +37,24 @@ export function MembersSidebar({ onOpenChange, }: MembersSidebarProps) { const channelId = channel?.id ?? null; + const queryClient = useQueryClient(); const membersQuery = useChannelMembersQuery(channelId, open); const addMembersMutation = useAddChannelMembersMutation(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 + : null; const rawMembers = membersQuery.data ?? []; const { people, bots, isBot, isMyBot, managedAgentsQuery } = @@ -127,8 +145,9 @@ export function MembersSidebar({ function renderMemberCard(member: ChannelMember, memberIsBot: boolean) { return ( { + void changeRoleMutation.mutateAsync({ pubkey: m.pubkey, role }); + }} onManagedAgentAction={(agent) => { void handleAgentLifecycleAction(agent); }} @@ -250,12 +272,12 @@ 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 8cac47e4893..8b5625ea077 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,23 +147,32 @@ export function MembersSidebarMemberCard({ ); } +const PEOPLE_ROLES = ["admin", "member", "guest"] 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; }) { + const showChangeRole = + canChangeRole && !memberIsBot && member.role !== "owner"; + return ( @@ -173,19 +198,49 @@ function MemberActionsMenu({ {getManagedAgentActionIcon(managedAgent)} {getManagedAgentPrimaryActionLabel(managedAgent)} - {canRemoveMember ? : null} + {canRemoveMember || showChangeRole ? ( + + ) : null} ) : null} + {showChangeRole ? ( + + + + Change role + + + {PEOPLE_ROLES.map((role) => ( + onChangeRole(member, role)} + > + {role[0]?.toUpperCase()} + {role.slice(1)} + {member.role === role ? " (current)" : ""} + + ))} + + + ) : null} {canRemoveMember ? ( - onRemoveMember(member)} - > - - Remove from channel - + <> + {showChangeRole ? : 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 }); }