diff --git a/web/src/lib/api/orgProfile.test.ts b/web/src/lib/api/orgProfile.test.ts index c19b1f3cd..daaa1f544 100644 --- a/web/src/lib/api/orgProfile.test.ts +++ b/web/src/lib/api/orgProfile.test.ts @@ -4,7 +4,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getOrCreateDelegatedSession } from "#lib/api/actAs.ts"; import { awaitIndexedOrSaved } from "#lib/api/awaitRecord.ts"; import { createBobbinClient } from "#lib/api/client.ts"; -import { saveOrganizationAvatar, saveOrganizationProfile } from "#lib/api/orgProfile.ts"; +import { + organizationHandleAgent, + saveOrganizationAvatar, + saveOrganizationProfile +} from "#lib/api/orgProfile.ts"; import { putProfile, uploadProfileAvatar } from "#lib/api/profile.ts"; vi.mock("#lib/api/actAs.ts", () => ({ getOrCreateDelegatedSession: vi.fn() })); @@ -45,6 +49,46 @@ describe("saveOrganizationProfile", () => { }); }); +describe("organizationHandleAgent", () => { + const withScope = (sub: string, scope: string) => + ({ sub, session: { token: { scope } }, signOut: vi.fn() }) as unknown as OAuthUserAgent; + const granted = "atproto identity:handle"; + + it("uses the stored organization session when it can change the handle", async () => { + const stored = withScope(orgDid, "atproto identity:*"); + vi.mocked(getOrCreateDelegatedSession).mockResolvedValue(stored); + + await expect( + organizationHandleAgent(withScope("did:plc:controller", granted), orgDid) + ).resolves.toBe(stored); + expect(stored.signOut).not.toHaveBeenCalled(); + }); + + it("replaces an organization session from before the owner allowed handle changes", async () => { + const stale = withScope(orgDid, "atproto repo:*"); + const fresh = withScope(orgDid, "atproto identity:*"); + vi.mocked(getOrCreateDelegatedSession) + .mockResolvedValueOnce(stale) + .mockResolvedValueOnce(fresh); + vi.mocked(stale.signOut).mockResolvedValue(); + + await expect( + organizationHandleAgent(withScope("did:plc:controller", granted), orgDid) + ).resolves.toBe(fresh); + expect(stale.signOut).toHaveBeenCalled(); + }); + + it("keeps the organization session while the owner's own session can't change handles", async () => { + const stale = withScope(orgDid, "atproto repo:*"); + vi.mocked(getOrCreateDelegatedSession).mockResolvedValue(stale); + + await expect( + organizationHandleAgent(withScope("did:plc:controller", "atproto repo:*"), orgDid) + ).resolves.toBe(stale); + expect(stale.signOut).not.toHaveBeenCalled(); + }); +}); + describe("saveOrganizationAvatar", () => { it("uploads with the organization's session, starting a record if there is none", async () => { const image = new Blob(["png"], { type: "image/png" }); diff --git a/web/src/lib/api/orgProfile.ts b/web/src/lib/api/orgProfile.ts index 27d1e4de9..215ab0cc6 100644 --- a/web/src/lib/api/orgProfile.ts +++ b/web/src/lib/api/orgProfile.ts @@ -1,6 +1,7 @@ import type { Did } from "@atcute/lexicons/syntax"; -import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { deleteStoredSession, type OAuthUserAgent } from "@atcute/oauth-browser-client"; import { getOrCreateDelegatedSession } from "#lib/api/actAs.ts"; +import { canChangeHandle } from "#lib/api/handle.ts"; import { PROFILE_COLLECTION } from "#lib/api/profile.ts"; import type { BobbinContext } from "#lib/api/client.ts"; import { saveAvatar, writeProfile } from "#lib/api/profileSettings.ts"; @@ -17,6 +18,17 @@ export const saveOrganizationProfile = async ( return writeProfile(ctx, orgAgent, written); }; +// an organization session made before its owner allowed handle changes keeps the old scope +export const organizationHandleAgent = async ( + controllerAgent: OAuthUserAgent, + orgDid: Did +): Promise => { + const orgAgent = await getOrCreateDelegatedSession(controllerAgent, orgDid); + if (canChangeHandle(orgAgent) || !canChangeHandle(controllerAgent)) return orgAgent; + await orgAgent.signOut().catch(() => deleteStoredSession(orgDid)); + return getOrCreateDelegatedSession(controllerAgent, orgDid); +}; + export const saveOrganizationAvatar = async ( ctx: BobbinContext, controllerAgent: OAuthUserAgent, diff --git a/web/src/routes/[handle]/settings/OrganizationHandle.svelte b/web/src/routes/[handle]/settings/OrganizationHandle.svelte new file mode 100644 index 000000000..0b8028e20 --- /dev/null +++ b/web/src/routes/[handle]/settings/OrganizationHandle.svelte @@ -0,0 +1,41 @@ + + +{#if !owner} + +{:else if agent.error} + +{:else} + { + await goto(resolve(`/${identity.did}?tab=settings` as "/")); + }} + /> +{/if} diff --git a/web/src/routes/[handle]/settings/OrganizationProfileTab.stories.svelte b/web/src/routes/[handle]/settings/OrganizationProfileTab.stories.svelte index 911575141..5d7f86c6d 100644 --- a/web/src/routes/[handle]/settings/OrganizationProfileTab.stories.svelte +++ b/web/src/routes/[handle]/settings/OrganizationProfileTab.stories.svelte @@ -38,6 +38,7 @@ `/${identity.handle}?tab=settings§ion=avatar` ); await expect(canvas.getByRole("textbox", { name: "Bio" })).toBeVisible(); + await expect(canvas.queryByRole("link", { name: "Change" })).not.toBeInTheDocument(); await expect( canvas.queryByRole("button", { name: "Delete organization" }) ).not.toBeInTheDocument(); @@ -53,6 +54,17 @@ }} /> + { + await expect(canvas.getByRole("link", { name: "Change" })).toHaveAttribute( + "href", + `/${identity.handle}?tab=settings§ion=handle` + ); + }} +/> + +> + {#snippet handleAction()} + {#if owner} + + {/if} + {/snippet} + diff --git a/web/src/routes/[handle]/settings/OrganizationSettingsTab.svelte b/web/src/routes/[handle]/settings/OrganizationSettingsTab.svelte index b69b0b832..3d31a76e6 100644 --- a/web/src/routes/[handle]/settings/OrganizationSettingsTab.svelte +++ b/web/src/routes/[handle]/settings/OrganizationSettingsTab.svelte @@ -3,7 +3,7 @@ import { profileIdentifier } from "#lib/api/identity.ts"; import { page } from "$app/state"; import { createLoad } from "#lib/action.svelte.ts"; - import { viewerControlsOrganization } from "#lib/api/organizations.ts"; + import { viewerControlsOrganization, viewerOwnsOrganization } from "#lib/api/organizations.ts"; import type { ProfileRecord } from "#lib/api/records.ts"; import { getAuth } from "#lib/auth.svelte.ts"; import settingsSettingspanelContentSkeletonFixture from "#lib/bones/settings-settingspanel--content-skeleton-fixture.bones.json"; @@ -17,6 +17,7 @@ import InviteMembers from "#routes/[handle]/settings/members/InviteMembers.svelte"; import MembersTab from "#routes/[handle]/settings/members/MembersTab.svelte"; import OrganizationAvatar from "#routes/[handle]/settings/OrganizationAvatar.svelte"; + import OrganizationHandle from "#routes/[handle]/settings/OrganizationHandle.svelte"; import OrganizationProfileTab from "#routes/[handle]/settings/OrganizationProfileTab.svelte"; import User from "$icon/user-round"; import Volleyball from "$icon/volleyball"; @@ -38,6 +39,11 @@ if (!agent || !delegationDid || !profile?.isOrganization) return false; return viewerControlsOrganization(agent, delegationDid, identity.did); }); + const owns = createLoad(async () => { + const agent = auth.agent; + if (!agent || !controls.data) return false; + return viewerOwnsOrganization(agent, delegationDid, identity.did); + }); const base = $derived(`/${profileIdentifier(identity.handle, identity.did)}?tab=settings`); const items: SettingsNavItem[] = $derived([ @@ -50,7 +56,11 @@ const section = $derived(page.url.searchParams.get("section") ?? "profile"); const active = $derived( - section === "avatar" ? "profile" : section === "invite" ? "members" : section + section === "avatar" || section === "handle" + ? "profile" + : section === "invite" + ? "members" + : section ); @@ -64,6 +74,10 @@ {#if section === "avatar"} + {:else if section === "handle"} + {#if owns.data !== undefined} + + {/if} {:else if section === "knots"} {:else if section === "spindles"} @@ -75,7 +89,7 @@ {:else if section === "invite"} {:else} - + {/if} {:else}