mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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) {
|
||||
|
||||
@@ -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())?)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user