diff --git a/e2e/USER_STORIES.md b/e2e/USER_STORIES.md index efd70061..0b1c4051 100644 --- a/e2e/USER_STORIES.md +++ b/e2e/USER_STORIES.md @@ -1521,6 +1521,20 @@ Acceptance: - 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-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 As a space owner, I want to see the spaces I host and spin up new ones, so that diff --git a/e2e/specs/admin.spec.ts b/e2e/specs/admin.spec.ts index 7684d04e..97ea6d05 100644 --- a/e2e/specs/admin.spec.ts +++ b/e2e/specs/admin.spec.ts @@ -83,6 +83,11 @@ const openSpaceMenu = (page: Page) => page.getByRole("button", {name: /space\.te const memberCard = (page: Page, name: string) => 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 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) }) +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}) => { await seed(({relay, user}) => { const space = relay("space") diff --git a/src/app/components/SpaceAdmins.svelte b/src/app/components/SpaceAdmins.svelte new file mode 100644 index 00000000..7e6491ad --- /dev/null +++ b/src/app/components/SpaceAdmins.svelte @@ -0,0 +1,112 @@ + + + + + + Admins + on + +
+ {#if $relay?.pubkey} +
+
+ +
+ Owner +
+ {/if} + {#if loading} + Loading admins... + {:else if $assignees.length === 0} +
+ Nobody else has been given management permissions. +
+ {:else} + {#each $assignees as { pubkey, methods } (pubkey)} +
+
+
+ +
+ {#if canEdit} + + {/if} +
+
+ {#each methods as method (method)} + {displayManagementMethod(method)} + {/each} +
+
+ {/each} + {/if} +
+
+ + + +
diff --git a/src/app/components/SpaceMemberMenu.svelte b/src/app/components/SpaceMemberMenu.svelte index 9f5759bb..8746cac0 100644 --- a/src/app/components/SpaceMemberMenu.svelte +++ b/src/app/components/SpaceMemberMenu.svelte @@ -1,11 +1,13 @@ + + + + + Edit Permissions + + Choose what @{$profileDisplay} can administer + + + {#if loading} + Loading permissions... + {:else} +
+ {#each groups as group (group.label)} +
+ {group.label} + {#each group.methods as { method, label } (method)} + + {/each} +
+ {/each} +
+ {/if} +
+ + + {#if canAssign || canUnassign} + + {/if} + +
diff --git a/src/app/management.ts b/src/app/management.ts index 3aecf092..1b151fcb 100644 --- a/src/app/management.ts +++ b/src/app/management.ts @@ -22,6 +22,93 @@ export const deriveSpaceBannedPubkeyItems = (url: string) => { 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([]), +) + +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]) => { let checkedAt = 0 diff --git a/src/routes/spaces/[relay]/directory/+page.svelte b/src/routes/spaces/[relay]/directory/+page.svelte index 33adeffa..580eb6ad 100644 --- a/src/routes/spaces/[relay]/directory/+page.svelte +++ b/src/routes/spaces/[relay]/directory/+page.svelte @@ -6,6 +6,7 @@ import MenuDots from "@assets/icons/menu-dots.svg?dataurl" import MinusCircle from "@assets/icons/minus-circle.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 Icon from "@lib/components/Icon.svelte" import Button from "@lib/components/Button.svelte" @@ -14,6 +15,7 @@ import SpaceBar from "@app/components/SpaceBar.svelte" import SpaceMember from "@app/components/SpaceMember.svelte" import SpaceInvite from "@app/components/SpaceInvite.svelte" + import SpaceAdmins from "@app/components/SpaceAdmins.svelte" import SpaceRoles from "@app/components/SpaceRoles.svelte" import SpaceMembersBanned from "@app/components/SpaceMembersBanned.svelte" import {deriveSpaceSupportedMethods} from "@app/management" @@ -35,6 +37,7 @@ ["createrole", "editrole", "deleterole"].some(method => $supportedMethods.includes(method)), ) const canListBans = $derived($supportedMethods.includes("listbannedpubkeys")) + const canListAdmins = $derived($supportedMethods.includes("listmethodassignees")) // Each member with their resolved roles (sorted by order). const memberList = derived([members, memberRoles, roles], ([$members, $memberRoles, $roles]) => { @@ -61,6 +64,11 @@ pushModal(SpaceRoles, {url}) } + const spaceAdmins = () => { + menuOpen = false + pushModal(SpaceAdmins, {url}) + } + const bannedMembers = () => { menuOpen = false pushModal(SpaceMembersBanned, {url}) @@ -110,7 +118,7 @@ Invite people - {#if canManageRoles || canListBans} + {#if canManageRoles || canListBans || canListAdmins}
+ + {/if} {#if canListBans}