Link a member's empty role list to the space's roles (#653)

This commit is contained in:
Coracle-Bot 2026-09-25 17:17:14 +00:00 committed by hodlbod
parent a7cb5fe4d6
commit 5c0617fbf1
5 changed files with 40 additions and 9 deletions

View file

@ -1451,6 +1451,8 @@ Acceptance:
- Searching the directory for the role's name filters to members holding it. - Searching the directory for the role's name filters to members holding it.
- Deleting a role warns that members keep their space membership, and confirming - Deleting a role warns that members keep their space membership, and confirming
removes it from the list; bob sees no "Manage Roles" option. removes it from the list; bob sees no "Manage Roles" option.
- A member's "Edit roles" in a space with no roles opens the roles list rather
than ending there.
### US-094 — Invite people to a space ### US-094 — Invite people to a space

View file

@ -290,6 +290,18 @@ test("US-093 create roles and assign them", async ({seed, as}) => {
await expect(memberCard(admin, "Bob Barnacle")).toBeVisible() await expect(memberCard(admin, "Bob Barnacle")).toBeVisible()
await expect(memberCard(admin, "Bob Barnacle").locator(".role-badge")).toHaveCount(0) await expect(memberCard(admin, "Bob Barnacle").locator(".role-badge")).toHaveCount(0)
// A space with no roles left offers the roles list from the member's own dialog
await memberCard(admin, "Bob Barnacle").getByRole("button").last().click()
await menuItem(admin, "Edit roles").click()
const bobsRoles = dialog(admin, "Edit Member")
await expect(bobsRoles.getByText("This space has no roles yet.")).toBeVisible()
await bobsRoles.getByRole("button", {name: "Manage Roles"}).click()
await expect(emptyState).toBeVisible()
const bob = await as(users.bob, spacePath(url) + "/directory") const bob = await as(users.bob, spacePath(url) + "/directory")
await expect(memberCard(bob, "Carol Cutter")).toBeVisible() await expect(memberCard(bob, "Carol Cutter")).toBeVisible()

View file

@ -3,6 +3,7 @@
import {sortBy} from "@welshman/lib" import {sortBy} from "@welshman/lib"
import {RelayRoles} from "@welshman/app" import {RelayRoles} from "@welshman/app"
import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl" import AltArrowLeft from "@assets/icons/alt-arrow-left.svg?dataurl"
import UsersGroup from "@assets/icons/users-group-rounded.svg?dataurl"
import Spinner from "@lib/components/Spinner.svelte" import Spinner from "@lib/components/Spinner.svelte"
import Button from "@lib/components/Button.svelte" import Button from "@lib/components/Button.svelte"
import Icon from "@lib/components/Icon.svelte" import Icon from "@lib/components/Icon.svelte"
@ -13,8 +14,11 @@
import ModalSubtitle from "@lib/components/ModalSubtitle.svelte" import ModalSubtitle from "@lib/components/ModalSubtitle.svelte"
import ModalFooter from "@lib/components/ModalFooter.svelte" import ModalFooter from "@lib/components/ModalFooter.svelte"
import RoleItem from "@app/components/RoleItem.svelte" import RoleItem from "@app/components/RoleItem.svelte"
import SpaceRoles from "@app/components/SpaceRoles.svelte"
import {app, profiles, relayManagement} from "@app/core" import {app, profiles, relayManagement} from "@app/core"
import {deriveUserCanManageRoles} from "@app/management"
import {deriveSpaceMemberRoles} from "@app/roles" import {deriveSpaceMemberRoles} from "@app/roles"
import {pushModal} from "@app/modal"
import {pushToast} from "@app/toast" import {pushToast} from "@app/toast"
type Props = { type Props = {
@ -30,9 +34,12 @@
const profileDisplay = $profiles.display(pubkey, [url]).$ const profileDisplay = $profiles.display(pubkey, [url]).$
const memberRoles = deriveSpaceMemberRoles(url) const memberRoles = deriveSpaceMemberRoles(url)
const initial = new Set(get(memberRoles).get(pubkey) ?? []) const initial = new Set(get(memberRoles).get(pubkey) ?? [])
const canManageRoles = deriveUserCanManageRoles(url)
const back = () => history.back() const back = () => history.back()
const manageRoles = () => pushModal(SpaceRoles, {url})
const toggle = (id: string) => { const toggle = (id: string) => {
const next = new Set(selected) const next = new Set(selected)
@ -114,8 +121,15 @@
<Icon icon={AltArrowLeft} /> <Icon icon={AltArrowLeft} />
Go back Go back
</Button> </Button>
{#if roles.length > 0}
<Button class="button button-primary" onclick={submit} disabled={loading}> <Button class="button button-primary" onclick={submit} disabled={loading}>
<Spinner {loading}>Save changes</Spinner> <Spinner {loading}>Save changes</Spinner>
</Button> </Button>
{:else if $canManageRoles}
<Button class="button button-primary" onclick={manageRoles}>
<Icon icon={UsersGroup} />
Manage Roles
</Button>
{/if}
</ModalFooter> </ModalFooter>
</Modal> </Modal>

View file

@ -150,6 +150,11 @@ export const deriveUserIsSpaceOwner = (url: string) =>
([$user, $relay]) => $user.pubkey === $relay?.pubkey, ([$user, $relay]) => $user.pubkey === $relay?.pubkey,
) )
export const deriveUserCanManageRoles = (url: string) =>
derived(deriveSpaceSupportedMethods(url), $methods =>
["createrole", "editrole", "deleterole"].some(method => $methods.includes(method)),
)
export const deriveUserCanCreateRoom = (url: string) => export const deriveUserCanCreateRoom = (url: string) =>
derived( derived(
[ [

View file

@ -18,7 +18,7 @@
import SpaceAdmins from "@app/components/SpaceAdmins.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, deriveUserCanManageRoles} 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"
@ -33,9 +33,7 @@
const members = $relayMemberLists.forUrl(url) const members = $relayMemberLists.forUrl(url)
const memberRoles = deriveSpaceMemberRoles(url) const memberRoles = deriveSpaceMemberRoles(url)
const supportedMethods = deriveSpaceSupportedMethods(url) const supportedMethods = deriveSpaceSupportedMethods(url)
const canManageRoles = $derived( const canManageRoles = deriveUserCanManageRoles(url)
["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")) const canListAdmins = $derived($supportedMethods.includes("listmethodassignees"))
@ -116,7 +114,7 @@
<Icon icon={AddCircle} /> <Icon icon={AddCircle} />
Invite people Invite people
</Button> </Button>
{#if canManageRoles || canListBans || canListAdmins} {#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"
@ -129,7 +127,7 @@
<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">
{#if canManageRoles} {#if $canManageRoles}
<li> <li>
<Button onclick={manageRoles}> <Button onclick={manageRoles}>
<Icon icon={UsersGroup} /> <Icon icon={UsersGroup} />