feat(desktop): add relay membership admin UI in settings (#454)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-02 16:56:41 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 088c4e7da7
commit e105c9ea5e
13 changed files with 904 additions and 4 deletions
+3 -3
View File
@@ -56,8 +56,8 @@ const overrides = new Map([
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display
["src/shared/api/types.ts", 560], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + UserStatus/UserStatusLookup (NIP-38)
["src-tauri/src/events.rs", 555], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave
["src/shared/api/types.ts", 570], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + UserStatus/UserStatusLookup (NIP-38) + RelayMember/RelayMemberRole types
["src-tauri/src/events.rs", 565], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role
["src-tauri/src/huddle/kokoro.rs", 980], // Kokoro ONNX TTS engine + three-tier G2P + ARPAbet→IPA + CoreML + synth_chunk() public API + style validation + hyphenated compound splitting + 23 unit tests
["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring
["src-tauri/src/huddle/models.rs", 850], // model download manager for Moonshine STT + Kokoro TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling
@@ -68,7 +68,7 @@ const overrides = new Map([
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 550], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + event parsing helpers
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations
["src/shared/api/tauri.ts", 1140], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field
["src/shared/api/tauri.ts", 1210], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role)
]);
async function walkFiles(directory) {
+2
View File
@@ -12,6 +12,7 @@ mod messages;
pub mod pairing;
mod personas;
mod profile;
mod relay_members;
mod social;
mod teams;
pub mod tokens;
@@ -31,6 +32,7 @@ pub use messages::*;
pub use pairing::*;
pub use personas::*;
pub use profile::*;
pub use relay_members::*;
pub use social::*;
pub use teams::*;
pub use tokens::*;
@@ -0,0 +1,64 @@
use reqwest::Method;
use tauri::State;
use crate::{
app_state::AppState,
events,
relay::{api_path, build_authed_request, send_json_request, submit_event},
};
#[tauri::command]
pub async fn list_relay_members(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
let request = build_authed_request(
&state.http_client,
Method::GET,
&api_path(&["relay", "members"]),
&state,
)?;
send_json_request(request).await
}
#[tauri::command]
pub async fn get_my_relay_membership(
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let request = build_authed_request(
&state.http_client,
Method::GET,
&api_path(&["relay", "members", "me"]),
&state,
)?;
send_json_request(request).await
}
#[tauri::command]
pub async fn add_relay_member(
target_pubkey: String,
role: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let builder = events::build_relay_admin_add(&target_pubkey, &role)?;
let result = submit_event(builder, &state).await?;
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn remove_relay_member(
target_pubkey: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let builder = events::build_relay_admin_remove(&target_pubkey)?;
let result = submit_event(builder, &state).await?;
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
#[tauri::command]
pub async fn change_relay_member_role(
target_pubkey: String,
new_role: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let builder = events::build_relay_admin_change_role(&target_pubkey, &new_role)?;
let result = submit_event(builder, &state).await?;
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
+47
View File
@@ -442,6 +442,53 @@ pub fn build_note(
Ok(EventBuilder::new(Kind::TextNote, content).tags(tags))
}
// ── Relay admin (NIP-43) ────────────────────────────────────────────────────
/// Allowed relay member roles for NIP-43 admin commands.
const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "member"];
fn check_relay_role(role: &str) -> Result<(), String> {
if !VALID_RELAY_ROLES.contains(&role) {
return Err(format!(
"invalid relay role \"{role}\" (expected one of: {})",
VALID_RELAY_ROLES.join(", ")
));
}
Ok(())
}
/// Kind 9030 — add a pubkey to the relay member list.
pub fn build_relay_admin_add(target_pubkey: &str, role: &str) -> Result<EventBuilder, String> {
check_pubkey(target_pubkey)?;
check_relay_role(role)?;
let tags = vec![
tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?,
tag(vec!["role", role])?,
];
Ok(EventBuilder::new(Kind::Custom(9030), "").tags(tags))
}
/// Kind 9031 — remove a pubkey from the relay member list.
pub fn build_relay_admin_remove(target_pubkey: &str) -> Result<EventBuilder, String> {
check_pubkey(target_pubkey)?;
let tags = vec![tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?];
Ok(EventBuilder::new(Kind::Custom(9031), "").tags(tags))
}
/// Kind 9032 — change the role of an existing relay member.
pub fn build_relay_admin_change_role(
target_pubkey: &str,
new_role: &str,
) -> Result<EventBuilder, String> {
check_pubkey(target_pubkey)?;
check_relay_role(new_role)?;
let tags = vec![
tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?,
tag(vec!["role", new_role])?,
];
Ok(EventBuilder::new(Kind::Custom(9032), "").tags(tags))
}
/// Maximum contacts per contact list event.
const MAX_CONTACTS: usize = 10_000;
+5
View File
@@ -413,6 +413,11 @@ pub fn run() {
mint_token,
revoke_token,
revoke_all_tokens,
list_relay_members,
get_my_relay_membership,
add_relay_member,
remove_relay_member,
change_relay_member_role,
list_relay_agents,
list_managed_agents,
create_managed_agent,
+122
View File
@@ -0,0 +1,122 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
addRelayMember,
changeRelayMemberRole,
getMyRelayMembership,
listRelayMembers,
removeRelayMember,
} from "@/shared/api/tauri";
import type { RelayMember } from "@/shared/api/types";
export const relayMembersQueryKey = ["relayMembers"] as const;
export const myRelayMembershipQueryKey = ["myRelayMembership"] as const;
export function useRelayMembersQuery() {
return useQuery({
queryKey: relayMembersQueryKey,
queryFn: listRelayMembers,
staleTime: 30_000,
});
}
export function useMyRelayMembershipQuery() {
return useQuery({
queryKey: myRelayMembershipQueryKey,
queryFn: getMyRelayMembership,
staleTime: 60_000,
});
}
export function useAddRelayMemberMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ pubkey, role }: { pubkey: string; role: string }) =>
addRelayMember(pubkey, role),
onMutate: async ({ pubkey, role }) => {
await queryClient.cancelQueries({ queryKey: relayMembersQueryKey });
const previous =
queryClient.getQueryData<RelayMember[]>(relayMembersQueryKey);
queryClient.setQueryData<RelayMember[]>(relayMembersQueryKey, (old) => [
...(old ?? []),
{
pubkey,
role: role as RelayMember["role"],
addedBy: null,
createdAt: new Date().toISOString(),
},
]);
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(relayMembersQueryKey, context.previous);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: relayMembersQueryKey });
},
});
}
export function useRemoveRelayMemberMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (pubkey: string) => removeRelayMember(pubkey),
onMutate: async (pubkey) => {
await queryClient.cancelQueries({ queryKey: relayMembersQueryKey });
const previous =
queryClient.getQueryData<RelayMember[]>(relayMembersQueryKey);
queryClient.setQueryData<RelayMember[]>(relayMembersQueryKey, (old) =>
old?.filter((m) => m.pubkey.toLowerCase() !== pubkey.toLowerCase()),
);
return { previous };
},
onError: (_err, _pubkey, context) => {
if (context?.previous) {
queryClient.setQueryData(relayMembersQueryKey, context.previous);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: relayMembersQueryKey });
},
});
}
export function useChangeRelayMemberRoleMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ pubkey, newRole }: { pubkey: string; newRole: string }) =>
changeRelayMemberRole(pubkey, newRole),
onMutate: async ({ pubkey, newRole }) => {
await queryClient.cancelQueries({ queryKey: relayMembersQueryKey });
const previous =
queryClient.getQueryData<RelayMember[]>(relayMembersQueryKey);
queryClient.setQueryData<RelayMember[]>(relayMembersQueryKey, (old) =>
old?.map((m) =>
m.pubkey.toLowerCase() === pubkey.toLowerCase()
? { ...m, role: newRole as RelayMember["role"] }
: m,
),
);
return { previous };
},
onError: (_err, _vars, context) => {
if (context?.previous) {
queryClient.setQueryData(relayMembersQueryKey, context.previous);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: relayMembersQueryKey });
},
});
}
@@ -0,0 +1,187 @@
import * as React from "react";
import { toast } from "sonner";
import {
useAddRelayMemberMutation,
useRelayMembersQuery,
} from "@/features/relay-members/hooks";
import type { RelayMemberRole } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
const PUBKEY_REGEX = /^[0-9a-f]{64}$/;
const ROLE_OPTIONS: Array<{ value: RelayMemberRole; label: string }> = [
{ value: "member", label: "Member" },
{ value: "admin", label: "Admin" },
];
export function AddMemberDialog({
isOwner,
open,
onOpenChange,
}: {
isOwner: boolean;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const addMutation = useAddRelayMemberMutation();
const membersQuery = useRelayMembersQuery();
const [pubkey, setPubkey] = React.useState("");
const [role, setRole] = React.useState<RelayMemberRole>("member");
const normalizedPubkey = pubkey.trim().toLowerCase();
const isValidPubkey = PUBKEY_REGEX.test(normalizedPubkey);
const isAlreadyMember =
isValidPubkey &&
!addMutation.isPending &&
(membersQuery.data ?? []).some(
(m) => m.pubkey.toLowerCase() === normalizedPubkey,
);
const canAdd = isValidPubkey && !isAlreadyMember && !addMutation.isPending;
function reset() {
setPubkey("");
setRole("member");
addMutation.reset();
}
function handleOpenChange(next: boolean) {
if (!next) {
reset();
}
onOpenChange(next);
}
function handleAdd() {
if (!canAdd) return;
addMutation.mutate(
{ pubkey: normalizedPubkey, role },
{
onSuccess: () => {
toast.success("Member added");
handleOpenChange(false);
},
},
);
}
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
className="max-w-md overflow-hidden p-0"
data-testid="add-relay-member-dialog"
>
<div className="flex max-h-[85vh] flex-col">
<DialogHeader className="border-b border-border/60 px-6 py-5 pr-14">
<DialogTitle>Add member</DialogTitle>
<DialogDescription>
Add a user to this relay by their public key.
</DialogDescription>
</DialogHeader>
<form
className="contents"
onSubmit={(e) => {
e.preventDefault();
handleAdd();
}}
>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="space-y-4">
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="member-pubkey"
>
Public key
</label>
<Input
autoCapitalize="none"
autoCorrect="off"
data-testid="member-pubkey-input"
id="member-pubkey"
maxLength={64}
onChange={(e) => setPubkey(e.target.value)}
placeholder="64-character hex pubkey"
spellCheck={false}
value={pubkey}
/>
{pubkey.trim().length > 0 && !isValidPubkey ? (
<p className="text-xs text-destructive">
Must be exactly 64 lowercase hex characters.
</p>
) : null}
{isAlreadyMember ? (
<p className="text-xs text-destructive">
This pubkey is already a relay member.
</p>
) : null}
</div>
<div className="space-y-1.5">
<p className="text-sm font-medium">Role</p>
<div className="flex gap-2">
{ROLE_OPTIONS.filter(
(opt) => isOwner || opt.value === "member",
).map((opt) => (
<button
aria-pressed={role === opt.value}
className={cn(
"rounded-lg border px-3 py-1.5 text-sm transition-colors",
role === opt.value
? "border-primary bg-primary/10 text-foreground"
: "border-border/60 text-muted-foreground hover:bg-accent",
)}
data-testid={`member-role-${opt.value}`}
key={opt.value}
onClick={() => setRole(opt.value)}
type="button"
>
{opt.label}
</button>
))}
</div>
</div>
{addMutation.error instanceof Error ? (
<p className="rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{addMutation.error.message}
</p>
) : null}
</div>
</div>
<div className="flex justify-end gap-2 border-t border-border/60 bg-background/95 px-6 py-4">
<Button
data-testid="cancel-add-member"
onClick={() => handleOpenChange(false)}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="confirm-add-member"
disabled={!canAdd}
size="sm"
type="submit"
>
{addMutation.isPending ? "Adding..." : "Add member"}
</Button>
</div>
</form>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,84 @@
import { toast } from "sonner";
import { truncatePubkey } from "@/features/profile/lib/identity";
import { useRemoveRelayMemberMutation } from "@/features/relay-members/hooks";
import type { RelayMember } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
export function ConfirmRemoveDialog({
member,
displayName,
open,
onOpenChange,
}: {
member: RelayMember | null;
displayName: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const removeMutation = useRemoveRelayMemberMutation();
const label = displayName || (member ? truncatePubkey(member.pubkey) : "");
function handleOpenChange(next: boolean) {
if (!next) {
removeMutation.reset();
}
onOpenChange(next);
}
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent
className="max-w-sm"
data-testid="confirm-remove-member-dialog"
>
<DialogHeader>
<DialogTitle>Remove {label}?</DialogTitle>
<DialogDescription>
This will immediately revoke their access to the relay.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button
onClick={() => handleOpenChange(false)}
size="sm"
variant="outline"
>
Cancel
</Button>
<Button
data-testid="confirm-remove-member"
disabled={removeMutation.isPending || !member}
onClick={() => {
if (!member) return;
removeMutation.mutate(member.pubkey, {
onSuccess: () => {
toast.success("Member removed");
handleOpenChange(false);
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: "Failed to remove member",
);
},
});
}}
size="sm"
variant="destructive"
>
{removeMutation.isPending ? "Removing..." : "Remove"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,287 @@
import * as React from "react";
import { MoreHorizontal, Plus, Shield, ShieldCheck, User } from "lucide-react";
import { toast } from "sonner";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { truncatePubkey } from "@/features/profile/lib/identity";
import {
useChangeRelayMemberRoleMutation,
useMyRelayMembershipQuery,
useRelayMembersQuery,
} from "@/features/relay-members/hooks";
import type { RelayMember, RelayMemberRole } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { AddMemberDialog } from "./AddMemberDialog";
import { ConfirmRemoveDialog } from "./ConfirmRemoveDialog";
function formatRelativeDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60_000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function RoleBadge({ role }: { role: RelayMemberRole }) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
role === "owner" && "bg-primary/10 text-primary",
role === "admin" && "bg-blue-500/10 text-blue-500",
role === "member" && "bg-muted text-muted-foreground",
)}
>
{role}
</span>
);
}
function RoleIcon({ role }: { role: RelayMemberRole }) {
switch (role) {
case "owner":
return <ShieldCheck className="h-4 w-4 shrink-0 text-primary" />;
case "admin":
return <Shield className="h-4 w-4 shrink-0 text-blue-500" />;
default:
return <User className="h-4 w-4 shrink-0 text-muted-foreground" />;
}
}
function MemberRow({
member,
displayName,
currentPubkey,
viewerRole,
onRemove,
onChangeRole,
}: {
member: RelayMember;
displayName: string | null;
currentPubkey?: string;
viewerRole: RelayMemberRole | null;
onRemove: (member: RelayMember) => void;
onChangeRole: (pubkey: string, newRole: string) => void;
}) {
const isSelf = currentPubkey?.toLowerCase() === member.pubkey.toLowerCase();
const isOwner = viewerRole === "owner";
const isAdmin = viewerRole === "admin";
const canRemove =
!isSelf &&
((isAdmin && member.role === "member") ||
(isOwner && member.role !== "owner"));
const canChangeRole = !isSelf && isOwner && member.role !== "owner";
const showActions = canRemove || canChangeRole;
return (
<div
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 bg-background/60 px-3 py-2.5"
data-testid={`relay-member-row-${member.pubkey}`}
>
<div className="flex min-w-0 items-center gap-3">
<RoleIcon role={member.role} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">
{displayName || truncatePubkey(member.pubkey)}
</span>
<RoleBadge role={member.role} />
{isSelf ? (
<span className="text-xs text-muted-foreground">(you)</span>
) : null}
</div>
<p className="text-xs text-muted-foreground">
Joined {formatRelativeDate(member.createdAt)}
</p>
</div>
</div>
{showActions ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid={`relay-member-actions-${member.pubkey}`}
size="sm"
variant="ghost"
>
<MoreHorizontal className="h-3.5 w-3.5" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canChangeRole ? (
<>
{member.role === "member" ? (
<DropdownMenuItem
onClick={() => onChangeRole(member.pubkey, "admin")}
>
Make Admin
</DropdownMenuItem>
) : null}
{member.role === "admin" ? (
<DropdownMenuItem
onClick={() => onChangeRole(member.pubkey, "member")}
>
Make Member
</DropdownMenuItem>
) : null}
</>
) : null}
{canRemove && canChangeRole ? <DropdownMenuSeparator /> : null}
{canRemove ? (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => onRemove(member)}
>
Remove
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
}
const ROLE_ORDER: Record<string, number> = { owner: 0, admin: 1, member: 2 };
export function RelayMembersCard({
currentPubkey,
}: {
currentPubkey?: string;
}) {
const membersQuery = useRelayMembersQuery();
const myMembershipQuery = useMyRelayMembershipQuery();
const changeRoleMutation = useChangeRelayMemberRoleMutation();
const [addDialogOpen, setAddDialogOpen] = React.useState(false);
const [removeTarget, setRemoveTarget] = React.useState<RelayMember | null>(
null,
);
const members = React.useMemo(() => {
const raw = membersQuery.data ?? [];
return [...raw].sort(
(a, b) => (ROLE_ORDER[a.role] ?? 3) - (ROLE_ORDER[b.role] ?? 3),
);
}, [membersQuery.data]);
const myMembership = myMembershipQuery.data;
const isOwner = myMembership?.role === "owner";
const isAdmin = myMembership?.role === "admin";
const canManage = isOwner || isAdmin;
const memberPubkeys = React.useMemo(
() => members.map((m) => m.pubkey),
[members],
);
const profilesQuery = useUsersBatchQuery(memberPubkeys);
const profiles = profilesQuery.data?.profiles ?? {};
function handleChangeRole(pubkey: string, newRole: string) {
changeRoleMutation.mutate(
{ pubkey, newRole },
{
onSuccess: () => {
toast.success(`Role changed to ${newRole}`);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to change role",
);
},
},
);
}
return (
<section className="min-w-0" data-testid="settings-relay-members">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold tracking-tight">
Relay Members
</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Manage who has access to this relay.
</p>
</div>
{canManage ? (
<Button
data-testid="add-relay-member"
onClick={() => setAddDialogOpen(true)}
size="sm"
>
<Plus className="h-3.5 w-3.5" />
Add Member
</Button>
) : null}
</div>
{membersQuery.error instanceof Error ? (
<p className="mt-3 rounded-xl border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{membersQuery.error.message}
</p>
) : null}
{membersQuery.isLoading ? (
<p className="mt-4 text-sm text-muted-foreground">Loading members...</p>
) : members.length > 0 ? (
<div className="mt-4 space-y-2">
{members.map((member) => (
<MemberRow
currentPubkey={currentPubkey}
displayName={
profiles[member.pubkey.toLowerCase()]?.displayName ?? null
}
key={member.pubkey}
member={member}
onChangeRole={handleChangeRole}
onRemove={setRemoveTarget}
viewerRole={myMembership?.role ?? null}
/>
))}
</div>
) : membersQuery.isSuccess ? (
<p className="mt-4 text-sm text-muted-foreground">No members yet.</p>
) : null}
<AddMemberDialog
isOwner={isOwner}
onOpenChange={setAddDialogOpen}
open={addDialogOpen}
/>
<ConfirmRemoveDialog
member={removeTarget}
displayName={
removeTarget
? (profiles[removeTarget.pubkey.toLowerCase()]?.displayName ?? null)
: null
}
onOpenChange={(open) => {
if (!open) setRemoveTarget(null);
}}
open={removeTarget !== null}
/>
</section>
);
}
@@ -6,6 +6,7 @@ import {
Keyboard,
KeyRound,
MonitorCog,
Shield,
Moon,
Search,
Smartphone,
@@ -18,6 +19,7 @@ import type {
DesktopNotificationPermissionState,
NotificationSettings,
} from "@/features/notifications/hooks";
import { RelayMembersCard } from "@/features/relay-members/ui/RelayMembersCard";
import { TokenSettingsCard } from "@/features/tokens/ui/TokenSettingsCard";
import { cn } from "@/shared/lib/cn";
import { ACCENT_COLORS, useTheme } from "@/shared/theme/ThemeProvider";
@@ -35,6 +37,7 @@ export type SettingsSection =
| "appearance"
| "shortcuts"
| "tokens"
| "relay-members"
| "mobile"
| "updates"
| "doctor";
@@ -86,6 +89,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
label: "Tokens",
icon: KeyRound,
},
{
value: "relay-members",
label: "Members",
icon: Shield,
},
{
value: "mobile",
label: "Mobile",
@@ -254,6 +262,8 @@ export function renderSettingsSection(
return <KeyboardShortcutsCard />;
case "tokens":
return <TokenSettingsCard currentPubkey={props.currentPubkey} />;
case "relay-members":
return <RelayMembersCard currentPubkey={props.currentPubkey} />;
case "mobile":
return <MobilePairingCard currentPubkey={props.currentPubkey} />;
case "updates":
@@ -2,6 +2,7 @@ import * as React from "react";
import { getVersion } from "@tauri-apps/api/app";
import { X } from "lucide-react";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { cn } from "@/shared/lib/cn";
import {
renderSettingsSection,
@@ -76,6 +77,20 @@ export function SettingsView({
onSetNeedsActionNotificationsEnabled,
section,
}: SettingsViewProps) {
const myMembershipQuery = useMyRelayMembershipQuery();
const visibleSections = React.useMemo(() => {
const membership = myMembershipQuery.data;
return settingsSections.filter((s) => {
if (s.value === "relay-members") {
return (
membership != null &&
(membership.role === "owner" || membership.role === "admin")
);
}
return true;
});
}, [myMembershipQuery.data]);
const [isLoaded, setIsLoaded] = React.useState(false);
const [appVersion, setAppVersion] = React.useState<string | null>(null);
React.useEffect(() => {
@@ -159,7 +174,7 @@ export function SettingsView({
aria-label="Settings sections"
className="flex gap-1 overflow-x-auto px-3 py-3 md:flex-1 md:flex-col md:overflow-y-auto md:pt-1"
>
{settingsSections.map((entry) => (
{visibleSections.map((entry) => (
<SettingsSectionButton
active={entry.value === section}
isLoaded={isLoaded}
+66
View File
@@ -20,6 +20,8 @@ import type {
ManagedAgent,
ManagedAgentBackend,
RelayAgent,
RelayMember,
RelayMemberRole,
PresenceLookup,
PresenceStatus,
Profile,
@@ -294,6 +296,17 @@ type RawManagedAgentPrereqs = {
mcp: RawCommandAvailability;
};
type RawRelayMember = {
pubkey: string;
role: string;
added_by: string | null;
created_at: string;
};
type RawListRelayMembersResponse = {
members: RawRelayMember[];
};
type RawCanvasResponse = {
content: string | null;
updated_at: number | null;
@@ -924,6 +937,59 @@ export async function revokeAllTokens(): Promise<{ revokedCount: number }> {
return { revokedCount: response.revoked_count };
}
// ── Relay Members ────────────────────────────────────────────────────────────
function fromRawRelayMember(raw: RawRelayMember): RelayMember {
return {
pubkey: raw.pubkey,
role: raw.role as RelayMemberRole,
addedBy: raw.added_by,
createdAt: raw.created_at,
};
}
export async function listRelayMembers(): Promise<RelayMember[]> {
const response =
await invokeTauri<RawListRelayMembersResponse>("list_relay_members");
return response.members.map(fromRawRelayMember);
}
export async function getMyRelayMembership(): Promise<RelayMember | null> {
try {
const raw = await invokeTauri<RawRelayMember>("get_my_relay_membership");
return fromRawRelayMember(raw);
} catch (error) {
// "relay returned 404 Not Found" = not a relay member — return null so
// the UI hides the Members tab. Re-throw real errors (network, auth, 500)
// so React Query surfaces them.
if (
error instanceof Error &&
error.message.startsWith("relay returned 404")
) {
return null;
}
throw error;
}
}
export async function addRelayMember(
targetPubkey: string,
role: string,
): Promise<void> {
await invokeTauri("add_relay_member", { targetPubkey, role });
}
export async function removeRelayMember(targetPubkey: string): Promise<void> {
await invokeTauri("remove_relay_member", { targetPubkey });
}
export async function changeRelayMemberRole(
targetPubkey: string,
newRole: string,
): Promise<void> {
await invokeTauri("change_relay_member_role", { targetPubkey, newRole });
}
export async function listRelayAgents(): Promise<RelayAgent[]> {
return (await invokeTauri<RawRelayAgent[]>("list_relay_agents")).map(
fromRawRelayAgent,
+11
View File
@@ -237,6 +237,17 @@ export type SearchMessagesResponse = {
found: number;
};
// ── Relay Members ────────────────────────────────────────────────────────────
export type RelayMemberRole = "owner" | "admin" | "member";
export type RelayMember = {
pubkey: string;
role: RelayMemberRole;
addedBy: string | null;
createdAt: string;
};
export type TokenScope =
| "messages:read"
| "messages:write"