diff --git a/desktop/src/features/communities/communityDeletionPending.test.mjs b/desktop/src/features/communities/communityDeletionPending.test.mjs index aba0d12618..bb21db2777 100644 --- a/desktop/src/features/communities/communityDeletionPending.test.mjs +++ b/desktop/src/features/communities/communityDeletionPending.test.mjs @@ -63,11 +63,11 @@ test("single-slot A-B-A view retains exact bytes and blocks another owner", () = target.setItem("buzz:hosted-community-delete-pending:v1", bytes); assert.deepEqual(pendingCommunityDeletionForAccount("a".repeat(64), target), { owned: envelope, - blockedByAnotherAccount: false, + blockingOwnerPubkey: null, }); assert.deepEqual(pendingCommunityDeletionForAccount("b".repeat(64), target), { owned: null, - blockedByAnotherAccount: true, + blockingOwnerPubkey: "a".repeat(64), }); assert.equal( target.getItem("buzz:hosted-community-delete-pending:v1"), @@ -82,7 +82,7 @@ test("single-slot A-B-A view retains exact bytes and blocks another owner", () = ); assert.deepEqual(pendingCommunityDeletionForAccount("a".repeat(64), target), { owned: envelope, - blockedByAnotherAccount: false, + blockingOwnerPubkey: null, }); assert.deepEqual(publicDeletionRequest(envelope), { community_id: envelope.community_id, diff --git a/desktop/src/features/communities/communityDeletionPending.ts b/desktop/src/features/communities/communityDeletionPending.ts index bfb1589823..afa6cd70ca 100644 --- a/desktop/src/features/communities/communityDeletionPending.ts +++ b/desktop/src/features/communities/communityDeletionPending.ts @@ -161,11 +161,11 @@ export function pendingCommunityDeletionForAccount( storage: StorageLike = defaultStorage(), ): { owned: PendingCommunityDeletion | null; - blockedByAnotherAccount: boolean; + blockingOwnerPubkey: string | null; } { const stored = loadPendingCommunityDeletion(storage); if (!stored || !ownerPubkey) { - return { owned: null, blockedByAnotherAccount: false }; + return { owned: null, blockingOwnerPubkey: null }; } if ( pendingCommunityDeletionMatchesAccount( @@ -174,9 +174,9 @@ export function pendingCommunityDeletionForAccount( BUILDERLAB_BACKEND_ORIGIN, ) ) { - return { owned: stored, blockedByAnotherAccount: false }; + return { owned: stored, blockingOwnerPubkey: null }; } - return { owned: null, blockedByAnotherAccount: true }; + return { owned: null, blockingOwnerPubkey: stored.bound_owner_pubkey }; } export function publicDeletionRequest( diff --git a/desktop/src/features/communities/hostedCommunityApi.test.mjs b/desktop/src/features/communities/hostedCommunityApi.test.mjs index 971f6858ea..c3e3cafeea 100644 --- a/desktop/src/features/communities/hostedCommunityApi.test.mjs +++ b/desktop/src/features/communities/hostedCommunityApi.test.mjs @@ -121,6 +121,15 @@ test("shared unknown errors stay neutral on non-deletion surfaces when deletion }); test("limit_reached uses the server quota when available and retains the fallback", () => { + assert.equal( + hostedCommunityErrorMessage( + { code: "limit_reached" }, + undefined, + "fallback", + 0, + ), + "You can't create more communities right now.", + ); assert.equal( hostedCommunityErrorMessage( { code: "limit_reached" }, diff --git a/desktop/src/features/communities/hostedCommunityApi.ts b/desktop/src/features/communities/hostedCommunityApi.ts index 19a91b04fe..ae09d13ffc 100644 --- a/desktop/src/features/communities/hostedCommunityApi.ts +++ b/desktop/src/features/communities/hostedCommunityApi.ts @@ -100,7 +100,10 @@ export function hostedCommunityErrorMessage( missing_mapping: "Connect your Buzz identity before creating a community.", invalid_name: "Use lowercase letters, numbers, and hyphens.", taken: "That Buzz address is already taken.", - limit_reached: `You've reached the limit of ${displayedLimit} hosted communities.`, + limit_reached: + quotaLimit === 0 + ? "You can't create more communities right now." + : `You've reached the limit of ${displayedLimit} hosted communities.`, relay_unavailable: "Community provisioning is temporarily unavailable.", identity_already_bound: "This Builderlab account is connected to another Buzz identity.", diff --git a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx index 305d44699d..1a44ba4a22 100644 --- a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx @@ -89,8 +89,9 @@ export function HostedCommunitiesSettingsCard() { } | null>(null); const [pendingDeletion, setPendingDeletion] = React.useState(null); - const [blockedByAnotherAccount, setBlockedByAnotherAccount] = - React.useState(false); + const [blockingOwnerPubkey, setBlockingOwnerPubkey] = React.useState< + string | null + >(null); const hiddenCommunityIds = React.useRef(new Set()); const recoveryAccount = React.useRef(null); const deleteInFlight = React.useRef(null); @@ -144,7 +145,7 @@ export function HostedCommunitiesSettingsCard() { adoptAccountOwner(nextOwner); const deletionView = pendingCommunityDeletionForAccount(nextOwner); setPendingDeletion(deletionView.owned); - setBlockedByAnotherAccount(deletionView.blockedByAnotherAccount); + setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey); setIdentity(identityResponse.identity ?? null); const nextCommunities = (communitiesResponse.communities ?? []).filter( (community) => @@ -524,9 +525,9 @@ export function HostedCommunitiesSettingsCard() { const startCommunityDeletion = (community: HostedCommunity) => { const deletionView = pendingCommunityDeletionForAccount(boundHex); - if (deletionView.owned || deletionView.blockedByAnotherAccount) { + if (deletionView.owned || deletionView.blockingOwnerPubkey) { setPendingDeletion(deletionView.owned); - setBlockedByAnotherAccount(deletionView.blockedByAnotherAccount); + setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey); return; } if ( @@ -558,7 +559,7 @@ export function HostedCommunitiesSettingsCard() { return; } setPendingDeletion(envelope); - setBlockedByAnotherAccount(false); + setBlockingOwnerPubkey(null); void runDeletion("Starting deletion…", envelope, async (generation) => { try { await invokeDeletion(envelope, "initial", generation); @@ -639,7 +640,7 @@ export function HostedCommunitiesSettingsCard() { recoveryAccount.current = accountKey; const deletionView = pendingCommunityDeletionForAccount(boundHex); setPendingDeletion(deletionView.owned); - setBlockedByAnotherAccount(deletionView.blockedByAnotherAccount); + setBlockingOwnerPubkey(deletionView.blockingOwnerPubkey); }, [auth, boundHex, loading]); const normalizedName = name.trim().toLowerCase(); @@ -790,17 +791,24 @@ export function HostedCommunitiesSettingsCard() { Check deletion status + {!deletionCapability ? ( +

+ Community deletion is unavailable right now, so this request + can't be checked. It stays saved on this device. +

+ ) : null} ) : null} - {auth && boundHex && blockedByAnotherAccount ? ( + {auth && boundHex && deletionCapability && blockingOwnerPubkey ? (
- Another account has a pending deletion on this device. Sign in with - that account to check deletion status before starting another deletion - here. + A deletion request from {safeNpub(blockingOwnerPubkey)} is still + pending on this device. Switch to that Buzz identity and use Check + deletion status before starting another deletion here. If you no + longer have that identity, contact support.
) : null} @@ -980,7 +988,7 @@ export function HostedCommunitiesSettingsCard() { busy || pendingDeletion?.community_id === community.id } deletionPending={ - pendingDeletion !== null || blockedByAnotherAccount + pendingDeletion !== null || blockingOwnerPubkey !== null } canDelete={ deletionCapability && diff --git a/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts index 734d6d5af6..a53763f082 100644 --- a/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts @@ -9,6 +9,19 @@ const OUTDIR = "test-results/hosted-communities"; const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); /** A second valid identity key, used only as a contradictory hosted npub. */ const OTHER_HEX = "b".repeat(64); +const PENDING_KEY = "buzz:hosted-community-delete-pending:v1"; +const OTHER_PENDING = { + community_id: "44444444-4444-4444-8444-444444444444", + host: "private-other.communities.buzz.xyz", + request_id: "55555555-5555-4555-8555-555555555555", + acknowledgement_version: 1, + bound_owner_pubkey: "a".repeat(64), + backend_origin: "https://app.builderlab.xyz", +}; +const OTHER_PENDING_BYTES = JSON.stringify(OTHER_PENDING); +const BLOCKED_COPY = `A deletion request from ${npubEncode(OTHER_PENDING.bound_owner_pubkey)} is still pending on this device. Switch to that Buzz identity and use Check deletion status before starting another deletion here. If you no longer have that identity, contact support.`; +const CAPABILITY_OFF_COPY = + "Community deletion is unavailable right now, so this request can't be checked. It stays saved on this device."; const DELETION_COMMUNITIES: Array<{ id: string; name: string; @@ -157,6 +170,101 @@ async function deletionPayloads(page: Page) { ); } +async function seedOtherOwnerPending(page: Page) { + await page.evaluate( + ({ key, bytes }) => window.localStorage.setItem(key, bytes), + { key: PENDING_KEY, bytes: OTHER_PENDING_BYTES }, + ); +} + +test("another Buzz identity's pending slot disables archived Delete without revealing its target", async ({ + page, +}) => { + await openDeletionFixture(page, { capability: true }); + await seedOtherOwnerPending(page); + await page.getByRole("button", { name: "Refresh" }).click(); + + const notice = page.getByText(BLOCKED_COPY, { exact: true }); + await expect(notice).toBeVisible(); + await expect(notice).not.toContainText(OTHER_PENDING.host); + await expect(notice).not.toContainText(OTHER_PENDING.community_id); + await expect(notice).not.toContainText(OTHER_PENDING.request_id); + await expect( + page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }) + .getByRole("button", { name: "Delete", exact: true }), + ).toBeDisabled(); + expect(await storedDeletionBytes(page)).toBe(OTHER_PENDING_BYTES); + expect(await deletionPayloads(page)).toHaveLength(0); +}); + +test("an occupied slot discovered during confirmation never sends or overwrites", async ({ + page, +}) => { + await openDeletionFixture(page, { capability: true }); + const archived = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Archived team" }); + await archived.getByRole("button", { name: "Delete", exact: true }).click(); + await page + .getByLabel( + "Type the exact host to continue: Exact-Host.communities.buzz.xyz", + ) + .fill("Exact-Host.communities.buzz.xyz"); + await page.getByRole("button", { name: "Continue" }).click(); + await seedOtherOwnerPending(page); + await page + .getByRole("button", { name: "Delete community permanently" }) + .click(); + + await expect(page.getByText(BLOCKED_COPY, { exact: true })).toBeVisible(); + await expect( + page.getByText(/Could not safely save the pending deletion request/), + ).toHaveCount(0); + expect(await storedDeletionBytes(page)).toBe(OTHER_PENDING_BYTES); + expect(await deletionPayloads(page)).toHaveLength(0); +}); + +test("another identity's blocked notice is hidden when deletion capability is off", async ({ + page, +}) => { + await openDeletionFixture(page, { capability: false }); + await seedOtherOwnerPending(page); + await page.getByRole("button", { name: "Refresh" }).click(); + + await expect(page.getByText(BLOCKED_COPY, { exact: true })).toHaveCount(0); + await expect(page.getByText(/pending deletion on this device/i)).toHaveCount( + 0, + ); + expect(await storedDeletionBytes(page)).toBe(OTHER_PENDING_BYTES); + expect(await deletionPayloads(page)).toHaveLength(0); +}); + +test("own pending request explains why Check is disabled when deletion capability is off", async ({ + page, +}) => { + await openDeletionFixture(page, { capability: false }); + const ownedBytes = JSON.stringify({ + ...OTHER_PENDING, + bound_owner_pubkey: DEFAULT_MOCK_PUBKEY, + }); + await page.evaluate( + ({ key, bytes }) => window.localStorage.setItem(key, bytes), + { key: PENDING_KEY, bytes: ownedBytes }, + ); + await page.getByRole("button", { name: "Refresh" }).click(); + + await expect( + page.getByText(CAPABILITY_OFF_COPY, { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Check deletion status" }), + ).toBeDisabled(); + expect(await storedDeletionBytes(page)).toBe(ownedBytes); + expect(await deletionPayloads(page)).toHaveLength(0); +}); + test("reopen sends nothing and Check resends the same saved request once", async ({ page, }) => { @@ -389,7 +497,9 @@ test("A-B-A owner switch retains exact bytes and replays only under A", async ({ page.getByRole("button", { name: "Check deletion status" }), ).toHaveCount(0); await expect( - page.getByText(/another account has a pending deletion/i), + page.getByText( + /A deletion request from npub1.* is still pending on this device/, + ), ).toBeVisible(); await expect.poll(() => storedDeletionBytes(page)).toBe(bytes); expect(await deletionPayloads(page)).toHaveLength(1);