List a space's admins and edit a member's permissions

This commit is contained in:
Coracle-Bot 2026-09-21 20:53:59 +00:00
parent 10004658ee
commit 90133dd465
7 changed files with 483 additions and 1 deletions

View file

@ -1521,6 +1521,20 @@ Acceptance:
- She still sees "Action Items", which is the queue `allowpubkey` resolves. - She still sees "Action Items", which is the queue `allowpubkey` resolves.
- admin, who owns the relay and so holds every method, sees all three. - admin, who owns the relay and so holds every method, sees all three.
### US-130 — Share out admin permissions
As admin, I want to hand a member individual management permissions and see who
holds what, so that moderation is shared without handing anyone the whole relay.
Acceptance:
- "Admins" in the directory's menu lists the space's owner and everyone holding
assigned methods, each with a badge per permission they hold.
- Checking permissions under a member's "Edit permissions" puts them in that
list, and unchecking those permissions takes them back out.
- A member given "List banned members" finds "Banned Members" in the directory
menu they had no menu in before, and still no "Admins".
### 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

@ -83,6 +83,11 @@ const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.te
const memberCard = (page: Page, name: string) => const memberCard = (page: Page, name: string) =>
page.locator(".card-interactive").filter({hasText: name}) page.locator(".card-interactive").filter({hasText: name})
// Exact, because several permission names are a substring of another — "Ban members" of "Unban
// members".
const permission = (scope: Locator, name: string) =>
scope.getByRole("checkbox", {name, exact: true})
const openEventMenu = (card: Locator) => menuButton(card).click() const openEventMenu = (card: Locator) => menuButton(card).click()
const articleCard = (page: Page, title: string) => const articleCard = (page: Page, title: string) =>
@ -715,6 +720,75 @@ test("US-097 work through the action-items queue", async ({seed, as}) => {
await expect(bobsSpace.getByRole("button", {name: /^Action Items/})).toHaveCount(0) await expect(bobsSpace.getByRole("button", {name: /^Action Items/})).toHaveCount(0)
}) })
test("US-130 share out admin permissions", async ({seed, as}) => {
const scenario = await seed(({relay, user}) => {
const space = relay("space")
space.room("general", {name: "General"})
space.join(user.admin, "general")
space.join(user.bob, "general")
space.profile(user.bob, {name: "Bob Barnacle"})
})
const {url} = scenario.space("space")
const admin = await as(users.admin, spacePath(url) + "/directory")
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
await admin.getByRole("button", {name: "More options"}).click()
await menuItem(admin, "Admins").click()
const admins = dialog(admin, "Admins")
// The owner holds every method implicitly, so the relay leaves them out of listmethodassignees
// and the client names them from the space's nip 11 pubkey instead.
await expect(admins.locator(".badge").filter({hasText: "Owner"})).toBeVisible()
await expect(admins.getByText("Nobody else has been given management permissions.")).toBeVisible()
await admins.getByRole("button", {name: "Go back"}).click()
await memberCard(admin, "Bob Barnacle").getByRole("button").last().click()
await menuItem(admin, "Edit permissions").click()
const permissions = dialog(admin, "Edit Permissions")
await permission(permissions, "Ban members").check()
await permission(permissions, "List banned members").check()
await permissions.getByRole("button", {name: "Save changes"}).click()
await expect(admin.getByRole("alert")).toContainText("Permissions updated!")
await admin.getByRole("button", {name: "More options"}).click()
await menuItem(admin, "Admins").click()
await expect(
admins.locator(".card").filter({hasText: "Bob Barnacle"}).locator(".badge"),
).toHaveText(["Ban members", "List banned members"])
await admins.getByRole("button", {name: "Go back"}).click()
// bob holds two methods now, so he gets the one control they cover and nothing else
const bob = await as(users.bob, spacePath(url) + "/directory")
await bob.getByRole("button", {name: "More options"}).click()
await expect(menuItem(bob, "Banned Members")).toBeVisible()
await expect(menuItem(bob, "Admins")).toHaveCount(0)
await memberCard(admin, "Bob Barnacle").getByRole("button").last().click()
await menuItem(admin, "Edit permissions").click()
await permission(permissions, "Ban members").uncheck()
await permission(permissions, "List banned members").uncheck()
await permissions.getByRole("button", {name: "Save changes"}).click()
await expect(admin.getByRole("alert")).toContainText("Permissions updated!")
await admin.getByRole("button", {name: "More options"}).click()
await menuItem(admin, "Admins").click()
await expect(admins.getByText("Nobody else has been given management permissions.")).toBeVisible()
})
test("US-098 browse and create hosted spaces", async ({seed, as}) => { test("US-098 browse and create hosted spaces", async ({seed, as}) => {
await seed(({relay, user}) => { await seed(({relay, user}) => {
const space = relay("space") const space = relay("space")

View file

@ -0,0 +1,112 @@
<script lang="ts">
import {onMount} from "svelte"
import {Relays} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Pen from "@assets/icons/pen.svg?dataurl"
import Badge from "@lib/components/Badge.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Spinner from "@lib/components/Spinner.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import Profile from "@app/components/Profile.svelte"
import RelayName from "@app/components/RelayName.svelte"
import SpaceMemberMethods from "@app/components/SpaceMemberMethods.svelte"
import {fromApp} from "@app/core"
import {
deriveSpaceMethodAssignees,
deriveSpaceSupportedMethods,
displayManagementMethod,
loadSpaceMethodAssignees,
} from "@app/management"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast"
type Props = {
url: string
}
const {url}: Props = $props()
const relay = fromApp($app => $app.use(Relays).one(url))
const assignees = deriveSpaceMethodAssignees(url)
const supportedMethods = deriveSpaceSupportedMethods(url)
const canEdit = $derived(
["assignmethod", "unassignmethod"].some(method => $supportedMethods.includes(method)),
)
const back = () => history.back()
const editMethods = (pubkey: string) => pushModal(SpaceMemberMethods, {url, pubkey})
let loading = $state(true)
onMount(async () => {
const error = await loadSpaceMethodAssignees(url)
if (error) {
pushToast({theme: "error", message: error})
}
loading = false
})
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Admins</ModalTitle>
<ModalSubtitle>on <RelayName {url} class="text-primary" /></ModalSubtitle>
</ModalHeader>
<div class="flex flex-col gap-2">
{#if $relay?.pubkey}
<div class="card flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile pubkey={$relay.pubkey} {url} />
</div>
<Badge>Owner</Badge>
</div>
{/if}
{#if loading}
<Spinner loading>Loading admins...</Spinner>
{:else if $assignees.length === 0}
<div class="card bg-surface p-4 text-sm opacity-70">
Nobody else has been given management permissions.
</div>
{:else}
{#each $assignees as { pubkey, methods } (pubkey)}
<div class="card flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0 flex-1">
<Profile {pubkey} {url} />
</div>
{#if canEdit}
<Button
class="button button-ghost button-sm button-square"
aria-label="Edit permissions"
onclick={() => editMethods(pubkey)}>
<Icon size={4} icon={Pen} />
</Button>
{/if}
</div>
<div class="flex flex-wrap gap-1">
{#each methods as method (method)}
<Badge class="badge-sm font-normal">{displayManagementMethod(method)}</Badge>
{/each}
</div>
</div>
{/each}
{/if}
</div>
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
</ModalFooter>
</Modal>

View file

@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import {onMount} from "svelte" import {onMount} from "svelte"
import Pen from "@assets/icons/pen.svg?dataurl" import Pen from "@assets/icons/pen.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import UserMinus from "@assets/icons/user-minus.svg?dataurl" import UserMinus from "@assets/icons/user-minus.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl" import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
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 SpaceMemberMethods from "@app/components/SpaceMemberMethods.svelte"
import SpaceMemberRoles from "@app/components/SpaceMemberRoles.svelte" import SpaceMemberRoles from "@app/components/SpaceMemberRoles.svelte"
import {profiles, relayManagement} from "@app/core" import {profiles, relayManagement} from "@app/core"
import {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpaceSupportedMethods} from "@app/management"
@ -26,11 +28,17 @@
const canBan = $derived($supportedMethods.includes("banpubkey")) const canBan = $derived($supportedMethods.includes("banpubkey"))
const canAssign = $derived($supportedMethods.includes("assignrole")) const canAssign = $derived($supportedMethods.includes("assignrole"))
const canUnassign = $derived($supportedMethods.includes("unassignrole")) const canUnassign = $derived($supportedMethods.includes("unassignrole"))
const canEditMethods = $derived(
$supportedMethods.includes("listmethodassignees") &&
["assignmethod", "unassignmethod"].some(method => $supportedMethods.includes(method)),
)
const back = () => history.back() const back = () => history.back()
const editRoles = () => pushModal(SpaceMemberRoles, {url, pubkey}) const editRoles = () => pushModal(SpaceMemberRoles, {url, pubkey})
const editMethods = () => pushModal(SpaceMemberMethods, {url, pubkey})
const removeMember = () => const removeMember = () =>
pushModal(Confirm, { pushModal(Confirm, {
title: "Remove Member", title: "Remove Member",
@ -79,6 +87,14 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canEditMethods}
<li>
<Button onclick={editMethods}>
<Icon icon={ShieldUser} />
Edit permissions
</Button>
</li>
{/if}
{#if canUnallow} {#if canUnallow}
<li> <li>
<Button onclick={removeMember}> <Button onclick={removeMember}>

View file

@ -0,0 +1,163 @@
<script lang="ts">
import {onMount} from "svelte"
import {get} from "svelte/store"
import {spec} from "@welshman/lib"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte"
import Modal from "@lib/components/Modal.svelte"
import ModalBody from "@lib/components/ModalBody.svelte"
import ModalHeader from "@lib/components/ModalHeader.svelte"
import ModalTitle from "@lib/components/ModalTitle.svelte"
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte"
import {profiles, relayManagement} from "@app/core"
import {
MANAGEMENT_METHOD_GROUPS,
deriveSpaceMethodAssignees,
deriveSpaceSupportedMethods,
loadSpaceMethodAssignees,
} from "@app/management"
import {pushToast} from "@app/toast"
type Props = {
url: string
pubkey: string
}
const {url, pubkey}: Props = $props()
const profileDisplay = $profiles.display(pubkey, [url]).$
const supportedMethods = deriveSpaceSupportedMethods(url)
const canAssign = $derived($supportedMethods.includes("assignmethod"))
const canUnassign = $derived($supportedMethods.includes("unassignmethod"))
// A relay can grant methods this client knows nothing about, and a list that left them out
// would revoke them on the next save.
const groups = $derived.by(() => {
const known = MANAGEMENT_METHOD_GROUPS.flatMap(group => group.methods.map(({method}) => method))
const extra = [...assigned].filter(method => !known.includes(method))
if (extra.length > 0) {
return [
...MANAGEMENT_METHOD_GROUPS,
{label: "Other", methods: extra.map(method => ({method, label: method}))},
]
}
return MANAGEMENT_METHOD_GROUPS
})
const back = () => history.back()
const toggle = (method: string) => {
const next = new Set(selected)
if (next.has(method)) {
next.delete(method)
} else {
next.add(method)
}
selected = next
}
const submit = async () => {
saving = true
try {
const management = $relayManagement.forUrl(url)
for (const method of selected) {
if (!assigned.has(method)) {
const {error} = await management.assignMethod(pubkey, method)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
}
for (const method of assigned) {
if (!selected.has(method)) {
const {error} = await management.unassignMethod(pubkey, method)
if (error) {
pushToast({theme: "error", message: error})
return
}
}
}
await loadSpaceMethodAssignees(url)
pushToast({message: "Permissions updated!"})
back()
} finally {
saving = false
}
}
let assigned = $state(new Set<string>())
let selected = $state(new Set<string>())
let loading = $state(true)
let saving = $state(false)
onMount(async () => {
const error = await loadSpaceMethodAssignees(url)
if (error) {
pushToast({theme: "error", message: error})
}
assigned = new Set(get(deriveSpaceMethodAssignees(url)).find(spec({pubkey}))?.methods ?? [])
selected = new Set(assigned)
loading = false
})
</script>
<Modal>
<ModalBody>
<ModalHeader>
<ModalTitle>Edit Permissions</ModalTitle>
<ModalSubtitle>
Choose what <span class="text-primary">@{$profileDisplay}</span> can administer
</ModalSubtitle>
</ModalHeader>
{#if loading}
<Spinner loading>Loading permissions...</Spinner>
{:else}
<div class="flex flex-col gap-4">
{#each groups as group (group.label)}
<div class="flex flex-col gap-2">
<strong class="text-sm opacity-70">{group.label}</strong>
{#each group.methods as { method, label } (method)}
<label class="card card-sm flex cursor-pointer justify-between gap-3">
{label}
<input
type="checkbox"
class="checkbox"
checked={selected.has(method)}
disabled={selected.has(method) ? !canUnassign : !canAssign}
onchange={() => toggle(method)} />
</label>
{/each}
</div>
{/each}
</div>
{/if}
</ModalBody>
<ModalFooter>
<Button class="button button-link" onclick={back}>
<Icon icon={AltArrowLeft} />
Go back
</Button>
{#if canAssign || canUnassign}
<Button class="button button-primary" onclick={submit} disabled={saving}>
<Spinner loading={saving}>Save changes</Spinner>
</Button>
{/if}
</ModalFooter>
</Modal>

View file

@ -22,6 +22,93 @@ export const deriveSpaceBannedPubkeyItems = (url: string) => {
return store return store
} }
// NIP-86 has no way to ask a relay which methods it can hand out — `supportedmethods` answers for
// the pubkey that signed the request — so the catalog of grantable methods is the client's own.
export const MANAGEMENT_METHOD_GROUPS = [
{
label: "Members",
methods: [
{method: "allowpubkey", label: "Add members"},
{method: "unallowpubkey", label: "Remove members"},
{method: "listallowedpubkeys", label: "List members"},
{method: "banpubkey", label: "Ban members"},
{method: "unbanpubkey", label: "Unban members"},
{method: "listbannedpubkeys", label: "List banned members"},
],
},
{
label: "Content",
methods: [
{method: "banevent", label: "Delete content"},
{method: "allowevent", label: "Dismiss reports"},
{method: "listbannedevents", label: "List deleted content"},
],
},
{
label: "Roles",
methods: [
{method: "createrole", label: "Create roles"},
{method: "editrole", label: "Edit roles"},
{method: "deleterole", label: "Delete roles"},
{method: "assignrole", label: "Assign roles"},
{method: "unassignrole", label: "Unassign roles"},
],
},
{
label: "Invites",
methods: [
{method: "createclaim", label: "Create invites"},
{method: "deleteclaim", label: "Delete invites"},
{method: "listclaims", label: "List invites"},
],
},
{
label: "Space",
methods: [
{method: "changerelayname", label: "Change the name"},
{method: "changerelaydescription", label: "Change the description"},
{method: "changerelayicon", label: "Change the icon"},
],
},
{
label: "Admins",
methods: [
{method: "assignmethod", label: "Grant permissions"},
{method: "unassignmethod", label: "Revoke permissions"},
{method: "listmethodassignees", label: "List admins"},
],
},
]
const methodLabels = new Map(
MANAGEMENT_METHOD_GROUPS.flatMap(group =>
group.methods.map(({method, label}): [string, string] => [method, label]),
),
)
export const displayManagementMethod = (method: string) => methodLabels.get(method) ?? method
export type MethodAssigneeItem = {
pubkey: string
methods: string[]
}
// One store per space rather than per user: the relay answers the same assignments to anyone
// allowed to ask, and the admin list has to agree with the editor that changes it.
export const deriveSpaceMethodAssignees = simpleCache(([url]: [url: string]) =>
writable<MethodAssigneeItem[]>([]),
)
export const loadSpaceMethodAssignees = async (url: string) => {
const {result, error} = await relayManagement.get().forUrl(url).listMethodAssignees()
if (result) {
deriveSpaceMethodAssignees(url).set(result)
}
return error
}
const deriveSupportedMethodsForPubkey = simpleCache(([, url]: [pubkey: string, url: string]) => { const deriveSupportedMethodsForPubkey = simpleCache(([, url]: [pubkey: string, url: string]) => {
let checkedAt = 0 let checkedAt = 0

View file

@ -6,6 +6,7 @@
import MenuDots from "@assets/icons/menu-dots.svg?dataurl" import MenuDots from "@assets/icons/menu-dots.svg?dataurl"
import MinusCircle from "@assets/icons/minus-circle.svg?dataurl" import MinusCircle from "@assets/icons/minus-circle.svg?dataurl"
import Magnifier from "@assets/icons/magnifier.svg?dataurl" import Magnifier from "@assets/icons/magnifier.svg?dataurl"
import ShieldUser from "@assets/icons/shield-user.svg?dataurl"
import {fly} from "@lib/transition" import {fly} from "@lib/transition"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
@ -14,6 +15,7 @@
import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceBar from "@app/components/SpaceBar.svelte"
import SpaceMember from "@app/components/SpaceMember.svelte" import SpaceMember from "@app/components/SpaceMember.svelte"
import SpaceInvite from "@app/components/SpaceInvite.svelte" import SpaceInvite from "@app/components/SpaceInvite.svelte"
import SpaceAdmins from "@app/components/SpaceAdmins.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 {deriveSpaceSupportedMethods} from "@app/management" import {deriveSpaceSupportedMethods} from "@app/management"
@ -35,6 +37,7 @@
["createrole", "editrole", "deleterole"].some(method => $supportedMethods.includes(method)), ["createrole", "editrole", "deleterole"].some(method => $supportedMethods.includes(method)),
) )
const canListBans = $derived($supportedMethods.includes("listbannedpubkeys")) const canListBans = $derived($supportedMethods.includes("listbannedpubkeys"))
const canListAdmins = $derived($supportedMethods.includes("listmethodassignees"))
// 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]) => {
@ -61,6 +64,11 @@
pushModal(SpaceRoles, {url}) pushModal(SpaceRoles, {url})
} }
const spaceAdmins = () => {
menuOpen = false
pushModal(SpaceAdmins, {url})
}
const bannedMembers = () => { const bannedMembers = () => {
menuOpen = false menuOpen = false
pushModal(SpaceMembersBanned, {url}) pushModal(SpaceMembersBanned, {url})
@ -110,7 +118,7 @@
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Invite people Invite people
</Button> </Button>
{#if canManageRoles || canListBans} {#if canManageRoles || canListBans || canListAdmins}
<div class="relative"> <div class="relative">
<Button <Button
class="button button-neutral button-sm button-square" class="button button-neutral button-sm button-square"
@ -131,6 +139,14 @@
</Button> </Button>
</li> </li>
{/if} {/if}
{#if canListAdmins}
<li>
<Button onclick={spaceAdmins}>
<Icon icon={ShieldUser} />
Admins
</Button>
</li>
{/if}
{#if canListBans} {#if canListBans}
<li> <li>
<Button onclick={bannedMembers}> <Button onclick={bannedMembers}>