Gate each space admin control on the management method behind it

This commit is contained in:
Coracle-Bot 2026-09-18 08:42:42 +00:00
parent 4fc3c02ced
commit e5debeacd8
22 changed files with 312 additions and 110 deletions

View file

@ -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,

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()
})

View file

@ -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 : []),
]),
)

View file

@ -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
</Button>
</li>
{#if $userIsAdmin}
{#if canBanEvent}
<li>
<Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} />

View file

@ -20,7 +20,7 @@
import ProfilePinnedNote from "@app/components/ProfilePinnedNote.svelte"
import ProfileStatus from "@app/components/ProfileStatus.svelte"
import {messagingRelayLists, profiles, relayManagement, user} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {deriveSpaceSupportedMethods} from "@app/management"
import {navigate, popModal, pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {goToChat, makeProfilePath} from "@app/routes"
@ -32,7 +32,11 @@
const {pubkey, url}: Props = $props()
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const supportedMethods = deriveSpaceSupportedMethods(url)
const canBan = $derived($supportedMethods.includes("banpubkey"))
const canUnallow = $derived($supportedMethods.includes("unallowpubkey"))
const canAllow = $derived($supportedMethods.includes("allowpubkey"))
const canListBans = $derived($supportedMethods.includes("listbannedpubkeys"))
const isSelf = $derived($user.pubkey === pubkey)
@ -80,7 +84,7 @@
let isBanned = $state(false)
$effect(() => {
if (url && $userIsAdmin) {
if (url && canListBans) {
$relayManagement
.forUrl(url)
.listBannedPubkeys()
@ -110,21 +114,25 @@
</Button>
</li>
{/if}
{#if $userIsAdmin}
{#if isBanned}
{#if isBanned}
{#if canAllow}
<li>
<Button onclick={restoreMember}>
<Icon size={4} icon={Restart} />
Restore Membership
</Button>
</li>
{:else}
{/if}
{:else}
{#if canUnallow}
<li>
<Button onclick={removeMember}>
<Icon size={4} icon={UserMinus} />
Remove Member
</Button>
</li>
{/if}
{#if canBan}
<li>
<Button class="text-error" onclick={banMember}>
<Icon size={4} icon={MinusCircle} />

View file

@ -28,7 +28,7 @@
import ZapModal from "@app/components/Zap.svelte"
import {app, user} from "@app/core"
import type {FeedContext} from "@app/feeds"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {deriveSpaceSupportedMethods} from "@app/management"
import {pushModal} from "@app/modal"
import {deriveDisplaysByPubkey} from "@app/social"
@ -123,7 +123,8 @@
}
}
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const supportedMethods = deriveSpaceSupportedMethods(url)
const canBanEvent = $derived($supportedMethods.includes("banevent"))
const onReportClick = () => pushModal(ReportDetails, {url, event})
@ -150,7 +151,7 @@
{#if $reactions.length > 0 || $zaps.length || $reports.length > 0 || children}
<div class="flex min-w-0 flex-wrap gap-2">
{#if url && $reports.length > 0 && $userIsAdmin}
{#if url && $reports.length > 0 && canBanEvent}
<Button
data-tip={`This content has been reported as "${displayList(reportReasons)}".`}
class={cx(

View file

@ -10,7 +10,7 @@
import Button from "@lib/components/Button.svelte"
import Confirm from "@lib/components/Confirm.svelte"
import {app, deletes, profiles, relayManagement, relays, user} from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {deriveSpaceSupportedMethods} from "@app/management"
import {pushToast} from "@app/toast"
import {pushModal} from "@app/modal"
@ -23,7 +23,9 @@
const {url, event, onResolved, onClick}: Props = $props()
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const supportedMethods = deriveSpaceSupportedMethods(url)
const canBanEvent = $derived($supportedMethods.includes("banevent"))
const canBanPubkey = $derived($supportedMethods.includes("banpubkey"))
const etag = matchTag(tagSpec("e"), event.tags)
const ptag = matchTag(tagSpec("p"), event.tags)
@ -106,7 +108,7 @@
</Button>
</li>
{/if}
{#if $userIsAdmin}
{#if canBanEvent}
<li>
<Button onclick={dismissReport}>
<Icon icon={InboxOut} />
@ -121,13 +123,13 @@
</Button>
</li>
{/if}
{#if ptag}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
{/if}
{#if ptag && canBanPubkey}
<li>
<Button class="text-error" onclick={banMember}>
<Icon icon={MinusCircle} />
Ban User
</Button>
</li>
{/if}
</ul>

View file

@ -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
</Button>
</li>
{#if $userIsAdmin}
{#if canBanEvent}
<li>
<Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} />

View file

@ -7,6 +7,7 @@
import ProfileDetail from "@app/components/ProfileDetail.svelte"
import RoomName from "@app/components/RoomName.svelte"
import {app, relayManagement} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
import {addRoomMembers} from "@app/rooms"
@ -21,6 +22,9 @@
const h = tagValue(tagSpec("h"), event.tags) || ""
const supportedMethods = deriveSpaceSupportedMethods(url)
const canDismiss = $derived($supportedMethods.includes("banevent"))
const showProfile = () => pushModal(ProfileDetail, {pubkey: event.pubkey, url})
const dismiss = async () => {
@ -74,8 +78,10 @@
</span>
</div>
<div class="flex gap-2">
<Button class="button button-neutral button-sm" onclick={dismiss} disabled={loading}
>Dismiss</Button>
{#if canDismiss}
<Button class="button button-neutral button-sm" onclick={dismiss} disabled={loading}
>Dismiss</Button>
{/if}
<Button class="button button-primary button-sm" onclick={accept} disabled={loading}
>Accept</Button>
</div>

View file

@ -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}
<li>
<Button onclick={showActionItems}>
<Icon icon={Danger} />
@ -112,7 +120,7 @@
Hosting settings
</Link>
</li>
{:else if $userIsAdmin}
{:else if canEditSpace}
{@render actionButton(startEdit, Pen, "Edit Space")}
{/if}
{#if $userSpaceUrls.includes(url)}

View file

@ -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}
<Button class="button button-neutral w-full justify-start" onclick={showActionItems}>
<Icon size={4} icon={Danger} />
Action Items ({$actionItems.length})
@ -105,7 +113,7 @@
<Icon size={4} icon={ServerPath} />
Hosting settings
</Link>
{:else if $userIsAdmin}
{:else if canEditSpace}
{@render actionButton(startEdit, Pen, "Edit Space")}
{/if}
{#if $userSpaceUrls.includes(url)}

View file

@ -10,7 +10,6 @@
import SpaceMenuMobile from "@app/components/SpaceMenuMobile.svelte"
import SpaceMenuActions from "@app/components/SpaceMenuActions.svelte"
import RelayName from "@app/components/RelayName.svelte"
import {deriveUserIsSpaceAdmin} from "@app/management"
import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify} from "@app/settings"
import {pushModal} from "@app/modal"
@ -22,7 +21,6 @@
const {url, mobile = false}: Props = $props()
const userIsAdmin = deriveUserIsSpaceAdmin(url)
const actionItems = deriveSpaceActionItems(url)
const shouldNotify = deriveShouldNotify(url)
@ -54,7 +52,7 @@
<div
class={cx(
"absolute -right-3 top-0 h-2 w-2 rounded-full bg-primary text-primary-content transition-all",
$userIsAdmin && $actionItems.length > 0 ? "opacity-100" : "opacity-0",
$actionItems.length > 0 ? "opacity-100" : "opacity-0",
)}>
</div>
{#if $notificationSettings.push && !$shouldNotify}

View file

@ -10,6 +10,7 @@
import RoleEdit from "@app/components/RoleEdit.svelte"
import RoleAddMembers from "@app/components/RoleAddMembers.svelte"
import {relayManagement} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
@ -21,6 +22,11 @@
const {url, role, onClick}: Props = $props()
const supportedMethods = deriveSpaceSupportedMethods(url)
const canEdit = $derived($supportedMethods.includes("editrole"))
const canDelete = $derived($supportedMethods.includes("deleterole"))
const canAssign = $derived($supportedMethods.includes("assignrole"))
const back = () => history.back()
const editRole = () => pushModal(RoleEdit, {url, role})
@ -53,22 +59,28 @@
</script>
<ul class="menu whitespace-nowrap rounded-2xl bg-surface p-2" bind:this={ul}>
<li>
<Button onclick={addMembers}>
<Icon icon={AddCircle} />
Add members
</Button>
</li>
<li>
<Button onclick={editRole}>
<Icon icon={Pen} />
Edit role
</Button>
</li>
<li>
<Button class="text-error" onclick={confirmDelete}>
<Icon icon={TrashBin} />
Delete role
</Button>
</li>
{#if canAssign}
<li>
<Button onclick={addMembers}>
<Icon icon={AddCircle} />
Add members
</Button>
</li>
{/if}
{#if canEdit}
<li>
<Button onclick={editRole}>
<Icon icon={Pen} />
Edit role
</Button>
</li>
{/if}
{#if canDelete}
<li>
<Button class="text-error" onclick={confirmDelete}>
<Icon icon={TrashBin} />
Delete role
</Button>
</li>
{/if}
</ul>

View file

@ -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())}
<div class="card card-sm flex justify-between gap-2">
<RoleItem {role} />
<div class="shrink-0">
<MenuButton
class="button button-ghost button-sm button-square"
component={SpaceRoleMenu}
componentProps={{url, role}} />
</div>
{#if canEdit || canDelete || canAssign}
<div class="shrink-0">
<MenuButton
class="button button-ghost button-sm button-square"
component={SpaceRoleMenu}
componentProps={{url, role}} />
</div>
{/if}
</div>
{/each}
</div>
@ -64,9 +73,11 @@
<Icon icon={AltArrowLeft} />
Go back
</Button>
<Button class="button button-primary" onclick={createRole}>
<Icon icon={AddCircle} />
Create Role
</Button>
{#if canCreate}
<Button class="button button-primary" onclick={createRole}>
<Icon icon={AddCircle} />
Create Role
</Button>
{/if}
</ModalFooter>
</Modal>

View file

@ -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)),
)

View file

@ -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.

View file

@ -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 @@
<Icon icon={AddCircle} />
Invite people
</Button>
{#if $userIsAdmin}
{#if canManageRoles || canListBans}
<div class="relative">
<Button
class="button button-neutral button-sm button-square"
@ -117,18 +121,22 @@
<ul
transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
<li>
<Button onclick={manageRoles}>
<Icon icon={UsersGroup} />
Manage Roles
</Button>
</li>
<li>
<Button onclick={bannedMembers}>
<Icon icon={MinusCircle} />
Banned Members
</Button>
</li>
{#if canManageRoles}
<li>
<Button onclick={manageRoles}>
<Icon icon={UsersGroup} />
Manage Roles
</Button>
</li>
{/if}
{#if canListBans}
<li>
<Button onclick={bannedMembers}>
<Icon icon={MinusCircle} />
Banned Members
</Button>
</li>
{/if}
</ul>
</Popover>
{/if}