diff --git a/.agents/skills/flotilla-model/SKILL.md b/.agents/skills/flotilla-model/SKILL.md index fbd856f2..a379b596 100644 --- a/.agents/skills/flotilla-model/SKILL.md +++ b/.agents/skills/flotilla-model/SKILL.md @@ -95,11 +95,12 @@ that state, so a room re-created after deletion comes back. Membership (`members `membershipStatus(url, h)`) replays the 39002 snapshot, then newer 9000/9001 ops authored by an admin or the relay, then pending 9021/9022 requests, into `MembershipStatus.Initial | Pending | Granted`. `pendingJoins(url, h?)` lists unanswered join requests, and `deriveSpaceActionItems` -(`src/app/actionItems.ts`) merges them with reports into the admin queue. +(`src/app/actionItems.ts`) merges them with reports into the admin queue, keeping the half the +user holds a method for — reports under `banevent`, join requests under `allowpubkey`. `src/app/rooms.ts` puts space authority on top: -- `deriveUserIsRoomAdmin`: a space admin administers every room. +- `deriveUserIsRoomAdmin`: a space's staff administer every room. - `deriveUserRoomMembershipStatus`: an admin is always `Granted`. - `addRoomMembers`: allows each non-member at the relay (NIP-86 `allowPubkey`) before publishing 9000, because a room member the relay won't serve can't read the room. @@ -172,12 +173,13 @@ relay's URL, each call signed with a fresh NIP-98 event. Every method resolves t | `listClaims`, `createClaim` | `Access.prepareInvite` | | `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` | -Admin status is inferred. A relay answers `supportedmethods` with everything it implements rather -than what the caller may use, and refuses non-admins outright, so `deriveUserIsSpaceAdmin(url)` -only means the list came back non-empty (re-checked at most every five minutes per URL). To gate -one capability, check the method (`$supportedMethods.includes("banpubkey")`) and still handle an -error from the call, since a listed method can be blocked for a particular user. -`deriveUserCanCreateRoom` adds `ROOM_CREATE_PERMISSION` grants to space admins. +A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control +is gated on the method behind it: `deriveSpaceSupportedMethods(url)` (re-checked at most every +five minutes per pubkey and URL) and `$supportedMethods.includes("banpubkey")`. Still handle an +error from the call, since a listed method can be refused for a particular event or target. +`deriveUserIsSpaceStaff(url)` is only "the list came back non-empty", which is all there is to go +on for the room permissions NIP-86 has no method for — `deriveUserIsRoomAdmin` and +`deriveUserCanCreateRoom`, which also takes `ROOM_CREATE_PERMISSION` grants. The hosting backend in `src/app/hosting.ts` is a separate HTTP API at `HOSTING_BACKEND_URL` for relays the platform hosts. It authenticates with one NIP-98 header per pubkey, cached for a TTL, diff --git a/.agents/skills/flotilla-state/SKILL.md b/.agents/skills/flotilla-state/SKILL.md index 48cf47bc..a1d5fbe1 100644 --- a/.agents/skills/flotilla-state/SKILL.md +++ b/.agents/skills/flotilla-state/SKILL.md @@ -194,8 +194,16 @@ projections and repository derivations: // src/app/actionItems.ts export const deriveSpaceActionItems = (url: string) => derived( - [deriveEventsForUrl(url, [{kinds: [REPORT]}]), rooms.get().pendingJoins(url).$], - ([$reports, $pendingJoins]) => sortEventsDesc([...$reports, ...$pendingJoins]), + [ + deriveEventsForUrl(url, [{kinds: [REPORT]}]), + rooms.get().pendingJoins(url).$, + deriveSpaceSupportedMethods(url), + ], + ([$reports, $pendingJoins, $methods]) => + sortEventsDesc([ + ...($methods.includes("banevent") ? $reports : []), + ...($methods.includes("allowpubkey") ? $pendingJoins : []), + ]), ) ``` @@ -206,7 +214,7 @@ export const deriveSpaceActionItems = (url: string) => - plain verbs mutate: `addRoomMembers`, `reorderSpaceUrls` Rules that involve more than one plugin belong in these functions rather than in components. -"A space admin is a room admin" lives in `deriveUserIsRoomAdmin`. +"A space's staff are room admins" lives in `deriveUserIsRoomAdmin`. ### Hand-built indexes for hot paths diff --git a/.agents/skills/flotilla-views/SKILL.md b/.agents/skills/flotilla-views/SKILL.md index 53ddfb19..e4722516 100644 --- a/.agents/skills/flotilla-views/SKILL.md +++ b/.agents/skills/flotilla-views/SKILL.md @@ -299,7 +299,7 @@ How you bind depends on what the method returns: In a handler, call `.get()` on a projection instead of subscribing. The app modules add `derive*` factories over the same data: `deriveEvent` and `deriveEventsById` in `src/app/repository.ts`, -`deriveUserIsSpaceAdmin` in `src/app/management.ts`, `deriveRelayAuthError` in +`deriveSpaceSupportedMethods` in `src/app/management.ts`, `deriveRelayAuthError` in `src/app/access.ts`. Call them at the top of the script with fixed arguments, and wrap one in `$derived` only when its arguments change, as the thread page does for filters that wait on the root event. diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index 1f5d758f..382bd0bc 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -17,8 +17,9 @@ identities: - **alice**, **bob**, **carol** — ordinary members. Multi-user stories give each their own browser context against the same relay, so one genuinely observes another's writes over the wire. -- **admin** — the space admin, recognized by the relay's NIP-86 answers, which - is what unlocks the space, room, event and directory management surfaces. +- **admin** — the relay's owner, so every NIP-86 method comes back for them and + every space, room, event and directory management control is unlocked. A + member the relay answers with fewer methods gets fewer controls (US-127). The test architecture is described in `e2e/ARCHITECTURE.md`: real zooid relays in docker, with every socket and http request terminated in the test process. @@ -1467,6 +1468,20 @@ Acceptance: - "Remove Content" on a report deletes the reported message and clears the item; dismissing clears the item and leaves the content alone. +### US-127 — Show a member only the controls their methods cover + +As a space, we want each admin control gated on the management method behind it, +so that a member granted one method doesn't get an admin surface that only fails +when they use it. + +Acceptance: + +- On a space whose members hold `allowpubkey` alone, alice sees "Report Content" + on bob's message and no delete, no "Edit Space" in the space menu, and no + "More options" in the directory. +- She still sees "Action Items", which is the queue `allowpubkey` resolves. +- admin, who owns the relay and so holds every method, sees all three. + ### US-098 — Browse and create hosted spaces As a space owner, I want to see the spaces I host and spin up new ones, so that diff --git a/e2e/harness/zooid/config.ts b/e2e/harness/zooid/config.ts index f59ddca7..f56f5dcd 100644 --- a/e2e/harness/zooid/config.ts +++ b/e2e/harness/zooid/config.ts @@ -17,9 +17,11 @@ export const spaceTenants = { space: "space.test", other: "other.test", // Policy space.toml cannot express at the same time. `closed` refuses a join without an invite, - // `unsigned` serves events with their signatures stripped. + // `unsigned` serves events with their signatures stripped, `delegated` gives every member one + // management method. closed: "closed.test", unsigned: "unsigned.test", + delegated: "delegated.test", } as const // Public relays with no groups, which is where anything outside a space lives: `indexer` is what a diff --git a/e2e/harness/zooid/docker/config/delegated.toml b/e2e/harness/zooid/docker/config/delegated.toml new file mode 100644 index 00000000..06a9f548 --- /dev/null +++ b/e2e/harness/zooid/docker/config/delegated.toml @@ -0,0 +1,42 @@ +# A space that hands its ordinary members one management method. `member_methods` is what zooid +# answers `supportedmethods` with for a member who has been assigned nothing else, so a member here +# holds `allowpubkey` and nothing more — which is the case the client has to gate each admin control +# against. Everything else matches space.toml. +# +# `host` must equal the Host header the harness sends, which is the hostname of the url the client +# is given; anything else gets a 404 from the dispatcher instead of a relay. Keep it in step with +# `tenants` in harness/zooid/config.ts, and see harness/zooid/transport.ts for why it is a name +# that resolves nowhere. +host = "delegated.test" +schema = "e2e_delegated" +secret = "de1e6a7ed000000000000000000000000000000000000000000000000000000f" + +[info] +# Served to the client as nip-11. `name` is what a spec sees as the space's name, so it matches the +# name scenarios pass to `relay()`. `pubkey` is the owner, and on current zooid ownership is the +# grant that lets an identity manage the relay (IsOwner → CanManage), so it is admin's key — the +# identity seeding signs its room fixtures as. +name = "delegated" +pubkey = "6ada7b6eabb3a8349f88667d278a275c704b553a7c57f9d8156555986884a08e" +description = "Throwaway relay for Flotilla's end-to-end suite." + +[policy] +public_read = false +public_write = false +public_join = true +strip_signatures = false + +[groups] +enabled = true + +[management] +enabled = true +member_methods = ["allowpubkey"] + +# Blossom and push are off, and [livekit] is omitted entirely, because each of them would have +# the relay or the client talk to a service the test did not create. +[blossom] +enabled = false + +[push] +enabled = false diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts index a5a86463..7684d04e 100644 --- a/e2e/specs/admin.spec.ts +++ b/e2e/specs/admin.spec.ts @@ -1146,3 +1146,60 @@ test("US-122 export and import a hosted relay's data", async ({seed, as}) => { await expect(modal.getByText("Imported 1 event, skipped 1.")).toBeVisible() await expect(modal.getByText("line 2: invalid event")).toBeVisible() }) + +// The space menu button is labeled with the space's host, the way `openSpaceMenu` relies on for +// space.test. delegated.test grants every member one management method, so `supportedmethods` comes +// back with a single entry for a member and the whole list for admin, who owns the relay. +const openDelegatedMenu = (page: Page) => + page.getByRole("button", {name: /delegated\.test/}).click() + +test("US-127 a member holding one method gets only that control", async ({seed, as}) => { + const scenario = await seed(({relay, user, at}) => { + const space = relay("delegated") + + space.room("general", {name: "General"}) + space.join(user.admin, "general") + space.join(user.alice, "general") + space.join(user.bob, "general") + space.profile(user.alice, {name: "Alice Anchor"}) + space.profile(user.bob, {name: "Bob Barnacle"}) + space.message(user.bob, "general", "aye captain", at(2, HOUR)) + }) + + const {url} = scenario.space("delegated") + + // alice holds allowpubkey, which covers the join requests in the queue and nothing else + const alice = await as(users.alice, roomPath(url, "general")) + + await expect(alice.getByText("aye captain")).toBeVisible() + + await openMessageMenu(alice, "aye captain") + + await expect(menuItem(alice, "Report Content")).toBeVisible() + await expect(alice.getByRole("button", {name: "Delete Message", exact: true})).toHaveCount(0) + + await alice.goto(spacePath(url) + "/directory") + + await expect(alice.getByRole("button", {name: "Invite people"})).toBeVisible() + await expect(alice.getByRole("button", {name: "More options"})).toHaveCount(0) + + await openDelegatedMenu(alice) + + await expect(alice.getByRole("button", {name: /^Action Items/})).toBeVisible() + await expect(alice.getByRole("button", {name: "Edit Space", exact: true})).toHaveCount(0) + + // admin owns the relay, so every method comes back and every control is there + const admin = await as(users.admin, roomPath(url, "general")) + + await openMessageMenu(admin, "aye captain") + + await expect(menuItem(admin, "Delete Message")).toBeVisible() + + await admin.goto(spacePath(url) + "/directory") + + await expect(admin.getByRole("button", {name: "More options"})).toBeVisible() + + await openDelegatedMenu(admin) + + await expect(menuItem(admin, "Edit Space")).toBeVisible() +}) diff --git a/src/app/actionItems.ts b/src/app/actionItems.ts index d2c254c1..a0d3b887 100644 --- a/src/app/actionItems.ts +++ b/src/app/actionItems.ts @@ -1,12 +1,23 @@ import {derived} from "svelte/store" import {REPORT, sortEventsDesc} from "@welshman/util" import {rooms} from "@app/core" +import {deriveSpaceSupportedMethods} from "@app/management" import {deriveEventsForUrl} from "@app/repository" // Action items (admin review queue) +// A report is resolved by banning the event it names, a join request by allowing the pubkey, so +// the queue holds whichever of the two the user can actually act on. export const deriveSpaceActionItems = (url: string) => derived( - [deriveEventsForUrl(url, [{kinds: [REPORT]}]), rooms.get().pendingJoins(url).$], - ([$reports, $pendingJoins]) => sortEventsDesc([...$reports, ...$pendingJoins]), + [ + deriveEventsForUrl(url, [{kinds: [REPORT]}]), + rooms.get().pendingJoins(url).$, + deriveSpaceSupportedMethods(url), + ], + ([$reports, $pendingJoins, $methods]) => + sortEventsDesc([ + ...($methods.includes("banevent") ? $reports : []), + ...($methods.includes("allowpubkey") ? $pendingJoins : []), + ]), ) diff --git a/src/app/components/EventMenu.svelte b/src/app/components/EventMenu.svelte index 5790f75d..40ab7529 100644 --- a/src/app/components/EventMenu.svelte +++ b/src/app/components/EventMenu.svelte @@ -16,7 +16,7 @@ import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import PinboardSelect from "@app/components/PinboardSelect.svelte" import {shareEvent} from "@app/share" - import {deriveUserIsSpaceAdmin} from "@app/management" + import {deriveSpaceSupportedMethods} from "@app/management" import {pushModal} from "@app/modal" import {pushToast} from "@app/toast" import {app, relayManagement, user} from "@app/core" @@ -32,7 +32,8 @@ const {url, noun, event, onClick, customActions}: Props = $props() const isRoot = event.kind !== COMMENT - const userIsAdmin = deriveUserIsSpaceAdmin(url) + const supportedMethods = deriveSpaceSupportedMethods(url) + const canBanEvent = $derived($supportedMethods.includes("banevent")) const report = () => pushModal(Report, {url, event}) @@ -104,7 +105,7 @@ Report Content - {#if $userIsAdmin} + {#if canBanEvent}
  • {/if} - {#if $userIsAdmin} - {#if isBanned} + {#if isBanned} + {#if canAllow}
  • - {:else} + {/if} + {:else} + {#if canUnallow}
  • + {/if} + {#if canBan}
  • {/if} - {#if $userIsAdmin} + {#if canBanEvent}
  • {/if} - {#if ptag} -
  • - -
  • - {/if} + {/if} + {#if ptag && canBanPubkey} +
  • + +
  • {/if} diff --git a/src/app/components/RoomItemMenu.svelte b/src/app/components/RoomItemMenu.svelte index e1558b08..69b2be60 100644 --- a/src/app/components/RoomItemMenu.svelte +++ b/src/app/components/RoomItemMenu.svelte @@ -19,7 +19,7 @@ import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import ThreadCreate from "@app/components/ThreadCreate.svelte" import {app, relayManagement, roomPinLists, user} from "@app/core" - import {deriveUserIsSpaceAdmin} from "@app/management" + import {deriveSpaceSupportedMethods} from "@app/management" import {ROOM, deriveUserIsRoomAdmin} from "@app/rooms" import {shareEvent} from "@app/share" import {readAloud} from "@app/speech" @@ -36,9 +36,10 @@ const h = tagValue(tagSpec(ROOM), event.tags) ?? "" const pinIds = $roomPinLists.pins(url, h).$ - const userIsAdmin = deriveUserIsSpaceAdmin(url) + const supportedMethods = deriveSpaceSupportedMethods(url) const userIsRoomAdmin = deriveUserIsRoomAdmin(url, h) const isPinned = $derived($pinIds.includes(event.id)) + const canBanEvent = $derived($supportedMethods.includes("banevent")) const share = () => { onClick() @@ -171,7 +172,7 @@ Report Content - {#if $userIsAdmin} + {#if canBanEvent}
  • + {#if canDismiss} + + {/if} diff --git a/src/app/components/SpaceMenuActions.svelte b/src/app/components/SpaceMenuActions.svelte index e4ee3940..5ddfcd6b 100644 --- a/src/app/components/SpaceMenuActions.svelte +++ b/src/app/components/SpaceMenuActions.svelte @@ -19,7 +19,7 @@ import SpaceActionItems from "@app/components/SpaceActionItems.svelte" import {relays, user} from "@app/core" import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting" - import {deriveUserIsSpaceAdmin} from "@app/management" + import {deriveSpaceSupportedMethods} from "@app/management" import {userSpaceUrls} from "@app/rooms" import {deriveSpaceActionItems} from "@app/actionItems" import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings" @@ -33,7 +33,15 @@ const {url}: Props = $props() const relay = $relays.one(url) - const userIsAdmin = deriveUserIsSpaceAdmin(url) + const supportedMethods = deriveSpaceSupportedMethods(url) + const canReview = $derived( + ["banevent", "allowpubkey"].some(method => $supportedMethods.includes(method)), + ) + const canEditSpace = $derived( + ["changerelayname", "changerelaydescription", "changerelayicon"].some(method => + $supportedMethods.includes(method), + ), + ) const hostedRelay = deriveHostedRelay(url) const actionItems = deriveSpaceActionItems(url) const shouldNotify = deriveShouldNotify(url) @@ -77,7 +85,7 @@ {/snippet} {@render actionButton(createInvite, LinkRound, "Create Invite")} -{#if $userIsAdmin} +{#if canReview}
  • -{:else if $userIsAdmin} +{:else if canEditSpace} {@render actionButton(startEdit, Pen, "Edit Space")} {/if} {#if $userSpaceUrls.includes(url)} diff --git a/src/app/components/SpaceMenuActionsMobile.svelte b/src/app/components/SpaceMenuActionsMobile.svelte index 6f05e87e..198a6b14 100644 --- a/src/app/components/SpaceMenuActionsMobile.svelte +++ b/src/app/components/SpaceMenuActionsMobile.svelte @@ -19,7 +19,7 @@ import SpaceActionItems from "@app/components/SpaceActionItems.svelte" import {relays, user} from "@app/core" import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting" - import {deriveUserIsSpaceAdmin} from "@app/management" + import {deriveSpaceSupportedMethods} from "@app/management" import {userSpaceUrls} from "@app/rooms" import {deriveSpaceActionItems} from "@app/actionItems" import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings" @@ -33,7 +33,15 @@ const {url}: Props = $props() const relay = $relays.one(url) - const userIsAdmin = deriveUserIsSpaceAdmin(url) + const supportedMethods = deriveSpaceSupportedMethods(url) + const canReview = $derived( + ["banevent", "allowpubkey"].some(method => $supportedMethods.includes(method)), + ) + const canEditSpace = $derived( + ["changerelayname", "changerelaydescription", "changerelayicon"].some(method => + $supportedMethods.includes(method), + ), + ) const hostedRelay = deriveHostedRelay(url) const actionItems = deriveSpaceActionItems(url) const shouldNotify = deriveShouldNotify(url) @@ -76,7 +84,7 @@ {/snippet} {@render actionButton(createInvite, LinkRound, "Create Invite")} -{#if $userIsAdmin} +{#if canReview} - -
  • - -
  • -
  • - -
  • + {#if canAssign} +
  • + +
  • + {/if} + {#if canEdit} +
  • + +
  • + {/if} + {#if canDelete} +
  • + +
  • + {/if} diff --git a/src/app/components/SpaceRoles.svelte b/src/app/components/SpaceRoles.svelte index ea786721..0aa66f61 100644 --- a/src/app/components/SpaceRoles.svelte +++ b/src/app/components/SpaceRoles.svelte @@ -17,6 +17,7 @@ import RoleItem from "@app/components/RoleItem.svelte" import SpaceRoleMenu from "@app/components/SpaceRoleMenu.svelte" import {app} from "@app/core" + import {deriveSpaceSupportedMethods} from "@app/management" import {pushModal} from "@app/modal" type Props = { @@ -28,6 +29,12 @@ const relayRoles = $app.use(RelayRoles).forUrl(url).$ const roles = $derived(sortBy(role => [role.order(), role.label() ?? ""], $relayRoles)) + const supportedMethods = deriveSpaceSupportedMethods(url) + const canCreate = $derived($supportedMethods.includes("createrole")) + const canEdit = $derived($supportedMethods.includes("editrole")) + const canDelete = $derived($supportedMethods.includes("deleterole")) + const canAssign = $derived($supportedMethods.includes("assignrole")) + const back = () => history.back() const createRole = () => pushModal(RoleCreate, {url}) @@ -48,12 +55,14 @@ {#each roles as role (role.identifier())}
    -
    - -
    + {#if canEdit || canDelete || canAssign} +
    + +
    + {/if}
    {/each} @@ -64,9 +73,11 @@ Go back - + {#if canCreate} + + {/if} diff --git a/src/app/management.ts b/src/app/management.ts index cd59269a..3aecf092 100644 --- a/src/app/management.ts +++ b/src/app/management.ts @@ -55,9 +55,10 @@ export const deriveSpaceSupportedMethods = (url?: string) => // User -// Holding any management method at all is what makes someone staff here. A relay that still -// answers relay-wide tells us only that the call wasn't refused outright. -export const deriveUserIsSpaceAdmin = (url?: string) => +// Holding any management method at all is what makes someone staff. Every control the relay +// answers for is gated on its own method instead, so this is only for the room-level permissions +// NIP-86 has no method for. +export const deriveUserIsSpaceStaff = (url?: string) => derived(deriveSpaceSupportedMethods(url), $methods => $methods.length > 0) // The one identity a space names as its own, in its NIP-11 `pubkey`. Space-wide content with no @@ -73,8 +74,8 @@ export const deriveUserCanCreateRoom = (url: string) => [ user, deriveEventsForUrl(url, [{kinds: [ROOM_CREATE_PERMISSION]}]), - deriveUserIsSpaceAdmin(url), + deriveUserIsSpaceStaff(url), ], - ([$user, $events, $isAdmin]) => - $isAdmin || $events.some(event => tagValues(hexTags("p"), event.tags).includes($user.pubkey)), + ([$user, $events, $isStaff]) => + $isStaff || $events.some(event => tagValues(hexTags("p"), event.tags).includes($user.pubkey)), ) diff --git a/src/app/rooms.ts b/src/app/rooms.ts index 47929fe6..8ed88c85 100644 --- a/src/app/rooms.ts +++ b/src/app/rooms.ts @@ -19,7 +19,7 @@ import { user, writer, } from "@app/core" -import {deriveUserIsSpaceAdmin} from "@app/management" +import {deriveUserIsSpaceStaff} from "@app/management" import {makeRoomPath} from "@app/routes" export const ROOM = "h" @@ -167,12 +167,12 @@ export const deriveOtherVoiceRooms = (url: string) => return sortBy(roomComparator(url), uniq(result)) }) -// A space admin administers every room in it, so space admin implies room admin. +// A space's staff administer every room in it, so space staff implies room admin. export const deriveUserIsRoomAdmin = (url: string, h: string) => derived( - [user, rooms.get().forRoom(url, h), deriveUserIsSpaceAdmin(url)], - ([$user, $room, $isSpaceAdmin]) => - $isSpaceAdmin || Boolean($room?.admins?.pubkeys().includes($user.pubkey)), + [user, rooms.get().forRoom(url, h), deriveUserIsSpaceStaff(url)], + ([$user, $room, $isStaff]) => + $isStaff || Boolean($room?.admins?.pubkeys().includes($user.pubkey)), ) // Room membership is the relay's business, but a space admin outranks it. diff --git a/src/routes/spaces/[relay]/directory/+page.svelte b/src/routes/spaces/[relay]/directory/+page.svelte index ca6ce9f0..d0895e57 100644 --- a/src/routes/spaces/[relay]/directory/+page.svelte +++ b/src/routes/spaces/[relay]/directory/+page.svelte @@ -16,7 +16,7 @@ import SpaceInvite from "@app/components/SpaceInvite.svelte" import SpaceRoles from "@app/components/SpaceRoles.svelte" import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte" - import {deriveUserIsSpaceAdmin} from "@app/management" + import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpaceMemberRoles} from "@app/roles" import {relayMemberLists, relayRoles} from "@app/core" import {deriveDisplaysByPubkey} from "@app/social" @@ -30,7 +30,11 @@ const roles = $relayRoles.forUrl(url).$ const members = $relayMemberLists.forUrl(url) const memberRoles = deriveSpaceMemberRoles(url) - const userIsAdmin = deriveUserIsSpaceAdmin(url) + const supportedMethods = deriveSpaceSupportedMethods(url) + const canManageRoles = $derived( + ["createrole", "editrole", "deleterole"].some(method => $supportedMethods.includes(method)), + ) + const canListBans = $derived($supportedMethods.includes("listbannedpubkeys")) // Each member with their resolved roles (sorted by order). const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => { @@ -104,7 +108,7 @@ Invite people - {#if $userIsAdmin} + {#if canManageRoles || canListBans}
    - -
  • - -
  • + {#if canManageRoles} +
  • + +
  • + {/if} + {#if canListBans} +
  • + +
  • + {/if} {/if}