Gate each space admin control on the management method behind it
This commit is contained in:
parent
4fc3c02ced
commit
e5debeacd8
22 changed files with 312 additions and 110 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
42
e2e/harness/zooid/docker/config/delegated.toml
Normal file
42
e2e/harness/zooid/docker/config/delegated.toml
Normal 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
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -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 : []),
|
||||||
|
]),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue