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 `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 | admin or the relay, then pending 9021/9022 requests, into `MembershipStatus.Initial | Pending |
Granted`. `pendingJoins(url, h?)` lists unanswered join requests, and `deriveSpaceActionItems` 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: `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`. - `deriveUserRoomMembershipStatus`: an admin is always `Granted`.
- `addRoomMembers`: allows each non-member at the relay (NIP-86 `allowPubkey`) before publishing - `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. 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` | | `listClaims`, `createClaim` | `Access.prepareInvite` |
| `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` | | `changeRelayName`, `changeRelayDescription`, `changeRelayIcon` | `SpaceEdit` |
Admin status is inferred. A relay answers `supportedmethods` with everything it implements rather A relay answers `supportedmethods` with what the authenticated pubkey may call, so every control
than what the caller may use, and refuses non-admins outright, so `deriveUserIsSpaceAdmin(url)` is gated on the method behind it: `deriveSpaceSupportedMethods(url)` (re-checked at most every
only means the list came back non-empty (re-checked at most every five minutes per URL). To gate five minutes per pubkey and URL) and `$supportedMethods.includes("banpubkey")`. Still handle an
one capability, check the method (`$supportedMethods.includes("banpubkey")`) and still handle an error from the call, since a listed method can be refused for a particular event or target.
error from the call, since a listed method can be blocked for a particular user. `deriveUserIsSpaceStaff(url)` is only "the list came back non-empty", which is all there is to go
`deriveUserCanCreateRoom` adds `ROOM_CREATE_PERMISSION` grants to space admins. 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 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, 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 // src/app/actionItems.ts
export const deriveSpaceActionItems = (url: string) => export const deriveSpaceActionItems = (url: string) =>
derived( 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` - plain verbs mutate: `addRoomMembers`, `reorderSpaceUrls`
Rules that involve more than one plugin belong in these functions rather than in components. 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 ### 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*` 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`, 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 `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 `$derived` only when its arguments change, as the thread page does for filters that wait on the
root event. root event.

View file

@ -17,8 +17,9 @@ identities:
- **alice**, **bob**, **carol** — ordinary members. Multi-user stories give each - **alice**, **bob**, **carol** — ordinary members. Multi-user stories give each
their own browser context against the same relay, so one genuinely observes their own browser context against the same relay, so one genuinely observes
another's writes over the wire. another's writes over the wire.
- **admin** — the space admin, recognized by the relay's NIP-86 answers, which - **admin** — the relay's owner, so every NIP-86 method comes back for them and
is what unlocks the space, room, event and directory management surfaces. 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 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. 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; - "Remove Content" on a report deletes the reported message and clears the item;
dismissing clears the item and leaves the content alone. 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 ### 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 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", space: "space.test",
other: "other.test", other: "other.test",
// Policy space.toml cannot express at the same time. `closed` refuses a join without an invite, // 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", closed: "closed.test",
unsigned: "unsigned.test", unsigned: "unsigned.test",
delegated: "delegated.test",
} as const } as const
// Public relays with no groups, which is where anything outside a space lives: `indexer` is what a // 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("Imported 1 event, skipped 1.")).toBeVisible()
await expect(modal.getByText("line 2: invalid event")).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 {derived} from "svelte/store"
import {REPORT, sortEventsDesc} from "@welshman/util" import {REPORT, sortEventsDesc} from "@welshman/util"
import {rooms} from "@app/core" import {rooms} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management"
import {deriveEventsForUrl} from "@app/repository" import {deriveEventsForUrl} from "@app/repository"
// Action items (admin review queue) // 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) => export const deriveSpaceActionItems = (url: string) =>
derived( 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 EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import PinboardSelect from "@app/components/PinboardSelect.svelte" import PinboardSelect from "@app/components/PinboardSelect.svelte"
import {shareEvent} from "@app/share" import {shareEvent} from "@app/share"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveSpaceSupportedMethods} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
import {app, relayManagement, user} from "@app/core" import {app, relayManagement, user} from "@app/core"
@ -32,7 +32,8 @@
const {url, noun, event, onClick, customActions}: Props = $props() const {url, noun, event, onClick, customActions}: Props = $props()
const isRoot = event.kind !== COMMENT 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}) const report = () => pushModal(Report, {url, event})
@ -104,7 +105,7 @@
Report Content Report Content
</Button> </Button>
</li> </li>
{#if $userIsAdmin} {#if canBanEvent}
<li> <li>
<Button class="text-error" onclick={showAdminDelete}> <Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} /> <Icon size={4} icon={TrashBin2} />

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@
import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte" import EventDeleteConfirm from "@app/components/EventDeleteConfirm.svelte"
import ThreadCreate from "@app/components/ThreadCreate.svelte" import ThreadCreate from "@app/components/ThreadCreate.svelte"
import {app, relayManagement, roomPinLists, user} from "@app/core" 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 {ROOM, deriveUserIsRoomAdmin} from "@app/rooms"
import {shareEvent} from "@app/share" import {shareEvent} from "@app/share"
import {readAloud} from "@app/speech" import {readAloud} from "@app/speech"
@ -36,9 +36,10 @@
const h = tagValue(tagSpec(ROOM), event.tags) ?? "" const h = tagValue(tagSpec(ROOM), event.tags) ?? ""
const pinIds = $roomPinLists.pins(url, h).$ const pinIds = $roomPinLists.pins(url, h).$
const userIsAdmin = deriveUserIsSpaceAdmin(url) const supportedMethods = deriveSpaceSupportedMethods(url)
const userIsRoomAdmin = deriveUserIsRoomAdmin(url, h) const userIsRoomAdmin = deriveUserIsRoomAdmin(url, h)
const isPinned = $derived($pinIds.includes(event.id)) const isPinned = $derived($pinIds.includes(event.id))
const canBanEvent = $derived($supportedMethods.includes("banevent"))
const share = () => { const share = () => {
onClick() onClick()
@ -171,7 +172,7 @@
Report Content Report Content
</Button> </Button>
</li> </li>
{#if $userIsAdmin} {#if canBanEvent}
<li> <li>
<Button class="text-error" onclick={showAdminDelete}> <Button class="text-error" onclick={showAdminDelete}>
<Icon size={4} icon={TrashBin2} /> <Icon size={4} icon={TrashBin2} />

View file

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

View file

@ -19,7 +19,7 @@
import SpaceActionItems from "@app/components/SpaceActionItems.svelte" import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import {relays, user} from "@app/core" import {relays, user} from "@app/core"
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting" import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveSpaceSupportedMethods} from "@app/management"
import {userSpaceUrls} from "@app/rooms" import {userSpaceUrls} from "@app/rooms"
import {deriveSpaceActionItems} from "@app/actionItems" import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings" import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
@ -33,7 +33,15 @@
const {url}: Props = $props() const {url}: Props = $props()
const relay = $relays.one(url) 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 hostedRelay = deriveHostedRelay(url)
const actionItems = deriveSpaceActionItems(url) const actionItems = deriveSpaceActionItems(url)
const shouldNotify = deriveShouldNotify(url) const shouldNotify = deriveShouldNotify(url)
@ -77,7 +85,7 @@
{/snippet} {/snippet}
{@render actionButton(createInvite, LinkRound, "Create Invite")} {@render actionButton(createInvite, LinkRound, "Create Invite")}
{#if $userIsAdmin} {#if canReview}
<li> <li>
<Button onclick={showActionItems}> <Button onclick={showActionItems}>
<Icon icon={Danger} /> <Icon icon={Danger} />
@ -112,7 +120,7 @@
Hosting settings Hosting settings
</Link> </Link>
</li> </li>
{:else if $userIsAdmin} {:else if canEditSpace}
{@render actionButton(startEdit, Pen, "Edit Space")} {@render actionButton(startEdit, Pen, "Edit Space")}
{/if} {/if}
{#if $userSpaceUrls.includes(url)} {#if $userSpaceUrls.includes(url)}

View file

@ -19,7 +19,7 @@
import SpaceActionItems from "@app/components/SpaceActionItems.svelte" import SpaceActionItems from "@app/components/SpaceActionItems.svelte"
import {relays, user} from "@app/core" import {relays, user} from "@app/core"
import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting" import {deriveHostedRelay, HOSTING_ENABLED} from "@app/hosting"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveSpaceSupportedMethods} from "@app/management"
import {userSpaceUrls} from "@app/rooms" import {userSpaceUrls} from "@app/rooms"
import {deriveSpaceActionItems} from "@app/actionItems" import {deriveSpaceActionItems} from "@app/actionItems"
import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings" import {notificationSettings, deriveShouldNotify, setSpaceNotifications} from "@app/settings"
@ -33,7 +33,15 @@
const {url}: Props = $props() const {url}: Props = $props()
const relay = $relays.one(url) 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 hostedRelay = deriveHostedRelay(url)
const actionItems = deriveSpaceActionItems(url) const actionItems = deriveSpaceActionItems(url)
const shouldNotify = deriveShouldNotify(url) const shouldNotify = deriveShouldNotify(url)
@ -76,7 +84,7 @@
{/snippet} {/snippet}
{@render actionButton(createInvite, LinkRound, "Create Invite")} {@render actionButton(createInvite, LinkRound, "Create Invite")}
{#if $userIsAdmin} {#if canReview}
<Button class="button button-neutral w-full justify-start" onclick={showActionItems}> <Button class="button button-neutral w-full justify-start" onclick={showActionItems}>
<Icon size={4} icon={Danger} /> <Icon size={4} icon={Danger} />
Action Items ({$actionItems.length}) Action Items ({$actionItems.length})
@ -105,7 +113,7 @@
<Icon size={4} icon={ServerPath} /> <Icon size={4} icon={ServerPath} />
Hosting settings Hosting settings
</Link> </Link>
{:else if $userIsAdmin} {:else if canEditSpace}
{@render actionButton(startEdit, Pen, "Edit Space")} {@render actionButton(startEdit, Pen, "Edit Space")}
{/if} {/if}
{#if $userSpaceUrls.includes(url)} {#if $userSpaceUrls.includes(url)}

View file

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

View file

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

View file

@ -17,6 +17,7 @@
import RoleItem from "@app/components/RoleItem.svelte" import RoleItem from "@app/components/RoleItem.svelte"
import SpaceRoleMenu from "@app/components/SpaceRoleMenu.svelte" import SpaceRoleMenu from "@app/components/SpaceRoleMenu.svelte"
import {app} from "@app/core" import {app} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management"
import {pushModal} from "@app/modal" import {pushModal} from "@app/modal"
type Props = { type Props = {
@ -28,6 +29,12 @@
const relayRoles = $app.use(RelayRoles).forUrl(url).$ const relayRoles = $app.use(RelayRoles).forUrl(url).$
const roles = $derived(sortBy(role => [role.order(), role.label() ?? ""], $relayRoles)) 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 back = () => history.back()
const createRole = () => pushModal(RoleCreate, {url}) const createRole = () => pushModal(RoleCreate, {url})
@ -48,12 +55,14 @@
{#each roles as role (role.identifier())} {#each roles as role (role.identifier())}
<div class="card card-sm flex justify-between gap-2"> <div class="card card-sm flex justify-between gap-2">
<RoleItem {role} /> <RoleItem {role} />
<div class="shrink-0"> {#if canEdit || canDelete || canAssign}
<MenuButton <div class="shrink-0">
class="button button-ghost button-sm button-square" <MenuButton
component={SpaceRoleMenu} class="button button-ghost button-sm button-square"
componentProps={{url, role}} /> component={SpaceRoleMenu}
</div> componentProps={{url, role}} />
</div>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
@ -64,9 +73,11 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
<Button class="button button-primary" onclick={createRole}> {#if canCreate}
<Icon icon={AddCircle} /> <Button class="button button-primary" onclick={createRole}>
Create Role <Icon icon={AddCircle} />
</Button> Create Role
</Button>
{/if}
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

@ -55,9 +55,10 @@ export const deriveSpaceSupportedMethods = (url?: string) =>
// User // User
// Holding any management method at all is what makes someone staff here. A relay that still // Holding any management method at all is what makes someone staff. Every control the relay
// answers relay-wide tells us only that the call wasn't refused outright. // answers for is gated on its own method instead, so this is only for the room-level permissions
export const deriveUserIsSpaceAdmin = (url?: string) => // NIP-86 has no method for.
export const deriveUserIsSpaceStaff = (url?: string) =>
derived(deriveSpaceSupportedMethods(url), $methods => $methods.length > 0) 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 // 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, user,
deriveEventsForUrl(url, [{kinds: [ROOM_CREATE_PERMISSION]}]), deriveEventsForUrl(url, [{kinds: [ROOM_CREATE_PERMISSION]}]),
deriveUserIsSpaceAdmin(url), deriveUserIsSpaceStaff(url),
], ],
([$user, $events, $isAdmin]) => ([$user, $events, $isStaff]) =>
$isAdmin || $events.some(event => tagValues(hexTags("p"), event.tags).includes($user.pubkey)), $isStaff || $events.some(event => tagValues(hexTags("p"), event.tags).includes($user.pubkey)),
) )

View file

@ -19,7 +19,7 @@ import {
user, user,
writer, writer,
} from "@app/core" } from "@app/core"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveUserIsSpaceStaff} from "@app/management"
import {makeRoomPath} from "@app/routes" import {makeRoomPath} from "@app/routes"
export const ROOM = "h" export const ROOM = "h"
@ -167,12 +167,12 @@ export const deriveOtherVoiceRooms = (url: string) =>
return sortBy(roomComparator(url), uniq(result)) 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) => export const deriveUserIsRoomAdmin = (url: string, h: string) =>
derived( derived(
[user, rooms.get().forRoom(url, h), deriveUserIsSpaceAdmin(url)], [user, rooms.get().forRoom(url, h), deriveUserIsSpaceStaff(url)],
([$user, $room, $isSpaceAdmin]) => ([$user, $room, $isStaff]) =>
$isSpaceAdmin || Boolean($room?.admins?.pubkeys().includes($user.pubkey)), $isStaff || Boolean($room?.admins?.pubkeys().includes($user.pubkey)),
) )
// Room membership is the relay's business, but a space admin outranks it. // 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 SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceRoles from "@app/components/SpaceRoles.svelte" import SpaceRoles from "@app/components/SpaceRoles.svelte"
import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte" import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte"
import {deriveUserIsSpaceAdmin} from "@app/management" import {deriveSpaceSupportedMethods} from "@app/management"
import {deriveSpaceMemberRoles} from "@app/roles" import {deriveSpaceMemberRoles} from "@app/roles"
import {relayMemberLists, relayRoles} from "@app/core" import {relayMemberLists, relayRoles} from "@app/core"
import {deriveDisplaysByPubkey} from "@app/social" import {deriveDisplaysByPubkey} from "@app/social"
@ -30,7 +30,11 @@
const roles = $relayRoles.forUrl(url).$ const roles = $relayRoles.forUrl(url).$
const members = $relayMemberLists.forUrl(url) const members = $relayMemberLists.forUrl(url)
const memberRoles = deriveSpaceMemberRoles(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). // Each member with their resolved roles (sorted by order).
const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => { const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => {
@ -104,7 +108,7 @@
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Invite people Invite people
</Button> </Button>
{#if $userIsAdmin} {#if canManageRoles || canListBans}
<div class="relative"> <div class="relative">
<Button <Button
class="button button-neutral button-sm button-square" class="button button-neutral button-sm button-square"
@ -117,18 +121,22 @@
<ul <ul
transition:fly transition:fly
class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2"> class="menu bg-surface absolute right-0 z-popover mt-2 w-48 gap-1 rounded-2xl p-2">
<li> {#if canManageRoles}
<Button onclick={manageRoles}> <li>
<Icon icon={UsersGroup} /> <Button onclick={manageRoles}>
Manage Roles <Icon icon={UsersGroup} />
</Button> Manage Roles
</li> </Button>
<li> </li>
<Button onclick={bannedMembers}> {/if}
<Icon icon={MinusCircle} /> {#if canListBans}
Banned Members <li>
</Button> <Button onclick={bannedMembers}>
</li> <Icon icon={MinusCircle} />
Banned Members
</Button>
</li>
{/if}
</ul> </ul>
</Popover> </Popover>
{/if} {/if}