List a space's admins and edit a member's permissions
This commit is contained in:
parent
10004658ee
commit
90133dd465
7 changed files with 483 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
112
src/app/components/SpaceAdmins.svelte
Normal file
112
src/app/components/SpaceAdmins.svelte
Normal 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>
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
163
src/app/components/SpaceMemberMethods.svelte
Normal file
163
src/app/components/SpaceMemberMethods.svelte
Normal 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>
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue