From 0897a64f032ee4f6b41f0d7a867769dd644594fd Mon Sep 17 00:00:00 2001 From: eti Date: Mon, 21 Sep 2026 17:03:30 +0200 Subject: [PATCH] web/organizations: list and leave organizations from live data Signed-off-by: eti --- web/src/lib/api/organizations.test.ts | 184 ++++++++++++++++++ web/src/lib/api/organizations.ts | 99 ++++++++++ .../components/profile/organization/mock.ts | 43 ---- .../tabs/OrganizationsTab.stories.svelte | 111 +++++++++++ .../settings/tabs/OrganizationsTab.svelte | 97 +++++++-- 5 files changed, 479 insertions(+), 55 deletions(-) create mode 100644 web/src/lib/api/organizations.test.ts create mode 100644 web/src/lib/api/organizations.ts create mode 100644 web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte diff --git a/web/src/lib/api/organizations.test.ts b/web/src/lib/api/organizations.test.ts new file mode 100644 index 000000000..785d81490 --- /dev/null +++ b/web/src/lib/api/organizations.test.ts @@ -0,0 +1,184 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { getOrCreateDelegatedSession } from "$lib/api/actAs"; +import type { BobbinContext } from "$lib/api/client"; +import { REPO_COUNT } from "$lib/api/descriptors"; +import { enrich, TYPE_COUNT } from "$lib/api/enrich"; +import { leaveOrganization, listUserOrganizations, roleForScopes } from "$lib/api/organizations"; +import { listControlledAccounts, removeController } from "$lib/api/tranquil"; +import { forgetOrgDid } from "$lib/auth/accounts"; + +vi.mock("$lib/api/actAs", () => ({ getOrCreateDelegatedSession: vi.fn() })); +vi.mock("$lib/api/tranquil", () => ({ + listControlledAccounts: vi.fn(), + removeController: vi.fn() +})); +vi.mock("$lib/auth/accounts", () => ({ forgetOrgDid: vi.fn() })); +vi.mock(import("$lib/api/enrich"), async (importOriginal) => ({ + ...(await importOriginal()), + enrich: vi.fn() +})); + +const OWNER_SCOPES = + "atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage transition:generic"; +const EDITOR_SCOPES = "atproto repo:*?action=create blob:*/* rpc:*"; + +const controllerDid = "did:plc:controller" as Did; +const controller = { sub: controllerDid } as OAuthUserAgent; +const ctx = {} as BobbinContext; +const tangledDid = "did:plc:tangled" as Did; +const microcosmDid = "did:plc:microcosm" as Did; + +const profileUri = (did: Did) => `at://${did}/sh.tangled.actor.profile/self`; + +const countFor = (did: Did, count: number) => ({ + [did]: { [REPO_COUNT.source]: { [TYPE_COUNT]: count } } +}); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(listControlledAccounts).mockResolvedValue([ + { + did: tangledDid, + handle: "tangled.org", + grantedAt: "2025-01-14T09:00:00.000Z", + grantedScopes: OWNER_SCOPES + }, + { + did: microcosmDid, + handle: "microcosm.blue", + grantedAt: "2025-03-02T09:00:00.000Z", + grantedScopes: EDITOR_SCOPES + } + ]); + vi.mocked(enrich).mockResolvedValue({ + output: { + items: [ + { + uri: profileUri(tangledDid), + value: { + $type: "sh.tangled.actor.profile", + bluesky: false, + isOrganization: true, + description: "Social coding." + } + }, + { + uri: profileUri(microcosmDid), + value: { + $type: "sh.tangled.actor.profile", + bluesky: false, + isOrganization: true + } + } + ] + }, + data: { ...countFor(tangledDid, 24), ...countFor(microcosmDid, 9) } + } as never); +}); + +describe("roleForScopes", () => { + it("reads account management as ownership", () => { + expect(roleForScopes(OWNER_SCOPES)).toBe("owner"); + }); + + it("treats every other grant as membership", () => { + expect(roleForScopes(EDITOR_SCOPES)).toBe("member"); + expect(roleForScopes("")).toBe("member"); + }); +}); + +describe("listUserOrganizations", () => { + it("joins the controlled accounts with their profiles and repo counts", async () => { + const organizations = await listUserOrganizations(controller, ctx); + + expect(vi.mocked(enrich).mock.calls[0][1]).toMatchObject({ + xrpc: "sh.tangled.actor.getProfiles", + params: { actors: [profileUri(tangledDid), profileUri(microcosmDid)] } + }); + expect(organizations).toEqual([ + { + did: tangledDid, + handle: "tangled.org", + description: "Social coding.", + repos: 24, + role: "owner", + joinedAt: "2025-01-14T09:00:00.000Z" + }, + { + did: microcosmDid, + handle: "microcosm.blue", + description: undefined, + repos: 9, + role: "member", + joinedAt: "2025-03-02T09:00:00.000Z" + } + ]); + }); + + it("skips delegated accounts that are not organizations", async () => { + vi.mocked(enrich).mockResolvedValue({ + output: { + items: [ + { + uri: profileUri(microcosmDid), + value: { + $type: "sh.tangled.actor.profile", + bluesky: false, + isOrganization: false + } + } + ] + }, + data: countFor(tangledDid, 24) + } as never); + + const organizations = await listUserOrganizations(controller, ctx); + + expect(organizations.map((organization) => organization.did)).toEqual([tangledDid]); + }); + + it("still lists the organizations when the appview is unreachable", async () => { + vi.mocked(enrich).mockRejectedValue(new Error("bobbin is down")); + + const organizations = await listUserOrganizations(controller, ctx); + + expect(organizations).toHaveLength(2); + expect(organizations[0]).toMatchObject({ handle: "tangled.org", repos: 0, role: "owner" }); + }); + + it("does not reach for profiles when nothing is controlled", async () => { + vi.mocked(listControlledAccounts).mockResolvedValue([]); + + await expect(listUserOrganizations(controller, ctx)).resolves.toEqual([]); + expect(enrich).not.toHaveBeenCalled(); + }); +}); + +describe("leaveOrganization", () => { + it("revokes the viewer's grant and forgets the organization", async () => { + const signOut = vi.fn().mockResolvedValue(undefined); + vi.mocked(getOrCreateDelegatedSession).mockResolvedValue({ + signOut + } as unknown as OAuthUserAgent); + vi.mocked(removeController).mockResolvedValue({ success: true }); + + await leaveOrganization(controller, tangledDid); + + expect(getOrCreateDelegatedSession).toHaveBeenCalledWith(controller, tangledDid); + expect(removeController).toHaveBeenCalledWith(expect.anything(), controllerDid); + expect(signOut).toHaveBeenCalled(); + expect(forgetOrgDid).toHaveBeenCalledWith(tangledDid); + }); + + it("keeps the organization when the grant cannot be revoked", async () => { + vi.mocked(getOrCreateDelegatedSession).mockResolvedValue({ + signOut: vi.fn() + } as unknown as OAuthUserAgent); + vi.mocked(removeController).mockRejectedValue(new Error("Forbidden")); + + await expect(leaveOrganization(controller, tangledDid)).rejects.toThrow("Forbidden"); + expect(forgetOrgDid).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/lib/api/organizations.ts b/web/src/lib/api/organizations.ts new file mode 100644 index 000000000..d9f763c73 --- /dev/null +++ b/web/src/lib/api/organizations.ts @@ -0,0 +1,99 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import { deleteStoredSession, type OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { getOrCreateDelegatedSession } from "$lib/api/actAs"; +import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; +import { REPO_COUNT } from "$lib/api/descriptors"; +import { countOf, enrich, target } from "$lib/api/enrich"; +import type { ProfileRecord, RecordList } from "$lib/api/records"; +import { listControlledAccounts, removeController } from "$lib/api/tranquil"; +import { didFromUri } from "$lib/api/uri"; +import { forgetOrgDid } from "$lib/auth/accounts"; + +export type OrganizationRole = "owner" | "member"; + +export interface UserOrganization { + did: Did; + handle: string; + description?: string; + repos: number; + role: OrganizationRole; + joinedAt: string; +} + +export const roleForScopes = (grantedScopes: string): OrganizationRole => + grantedScopes + .split(/\s+/) + .some((scope) => scope.startsWith("account:") && scope.includes("action=manage")) + ? "owner" + : "member"; + +const profileUriFor = (did: Did): string => `at://${did}/sh.tangled.actor.profile/self`; + +interface OrganizationProfiles { + profiles: Map; + repos: Map; +} + +const emptyProfiles: OrganizationProfiles = { profiles: new Map(), repos: new Map() }; + +const fetchOrganizationProfiles = async ( + ctx: BobbinContext, + dids: readonly Did[], + init?: XrpcRequestInit +): Promise => { + const enriched = await enrich>( + ctx, + { + xrpc: "sh.tangled.actor.getProfiles", + params: { actors: dids.map(profileUriFor) }, + enrich: [target(REPO_COUNT, ["items[].uri"])] + }, + init + ); + const profiles = new Map(); + for (const item of enriched.output.items) { + profiles.set(didFromUri(item.uri), item.value); + } + const repos = new Map(dids.map((did) => [did, countOf(enriched.data, did, REPO_COUNT)])); + return { profiles, repos }; +}; + +export const listUserOrganizations = async ( + agent: OAuthUserAgent, + ctx: BobbinContext, + init?: XrpcRequestInit +): Promise => { + const accounts = await listControlledAccounts(agent, init); + if (accounts.length === 0) return []; + const dids = accounts.map((account) => account.did); + // the profile record carries the description, the appview carries the repo + // count; neither is essential, so the list still renders if bobbin is down + const { profiles, repos } = await fetchOrganizationProfiles(ctx, dids, init).catch( + () => emptyProfiles + ); + return accounts + .filter((account) => profiles.get(account.did)?.isOrganization !== false) + .map((account) => { + const profile = profiles.get(account.did); + return { + did: account.did, + handle: account.handle ?? account.did, + description: profile?.description, + repos: repos.get(account.did) ?? 0, + role: roleForScopes(account.grantedScopes), + joinedAt: account.grantedAt + }; + }); +}; + +export const leaveOrganization = async ( + controllerAgent: OAuthUserAgent, + orgDid: Did +): Promise => { + const orgAgent = await getOrCreateDelegatedSession(controllerAgent, orgDid); + await removeController(orgAgent, controllerAgent.sub as Did); + // the grant is gone, so the org session is dead whether or not the pds + // accepts the revocation + await orgAgent.signOut().catch(() => deleteStoredSession(orgDid)); + forgetOrgDid(orgDid); +}; diff --git a/web/src/lib/components/profile/organization/mock.ts b/web/src/lib/components/profile/organization/mock.ts index 8ead8ee9c..d24584709 100644 --- a/web/src/lib/components/profile/organization/mock.ts +++ b/web/src/lib/components/profile/organization/mock.ts @@ -30,49 +30,6 @@ export const viewerIsOrganizationOwner = true; export const organizationOnboardingComplete = false; -export interface OrganizationSummary { - did: string; - handle: string; - description?: string; - members: number; - repos: number; - role: "owner" | "member"; -} - -export const userOrganizations: OrganizationSummary[] = [ - { - did: "did:plc:organization-tangled", - handle: "tangled.org", - description: "Building a social coding platform on atproto.", - members: 86, - repos: 24, - role: "owner" - }, - { - did: "did:plc:organization-microcosm", - handle: "microcosm.blue", - description: "Infrastructure and tooling for the atmosphere.", - members: 12, - repos: 9, - role: "owner" - }, - { - did: "did:plc:organization-oceanography", - handle: "oceanography.tld", - description: "Open datasets and models for ocean research.", - members: 41, - repos: 17, - role: "member" - }, - { - did: "did:plc:organization-fieldnotes", - handle: "fieldnotes.tld", - members: 4, - repos: 2, - role: "member" - } -]; - export interface MemberCandidate { did: string; handle: string; diff --git a/web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte b/web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte new file mode 100644 index 000000000..4f195bd78 --- /dev/null +++ b/web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte @@ -0,0 +1,111 @@ + + + { + const search = canvas.getByRole("searchbox", { name: "Search organizations" }); + await waitFor(async () => + expect(canvas.getAllByRole("button", { name: /More actions for/ })).toHaveLength( + accounts.length + ) + ); + await userEvent.type(search, " TaNgLeD "); + await expect(canvas.getByText("tangled.org")).toBeVisible(); + await expect(canvas.getAllByRole("button", { name: /More actions for/ })).toHaveLength(1); + await userEvent.clear(search); + await userEvent.type(search, "no-such-organization"); + await expect(canvas.getByText("No organization matches your search")).toBeVisible(); + await expect( + canvas.queryByRole("button", { name: /More actions for/ }) + ).not.toBeInTheDocument(); + await userEvent.clear(search); + for (const account of accounts) { + await expect(canvas.getByText(account.handle)).toBeVisible(); + } + }} +/> + + { + const search = canvas.getByRole("searchbox", { name: "Search organizations" }); + await waitFor(async () => expect(canvas.getByText("tangled.org")).toBeVisible()); + await userEvent.type(search, "tangled.org"); + const owner = canvas.getByText("Owner", { exact: true }); + await expect(owner).toHaveClass("bg-background-inset"); + await expect(owner.querySelector('svg[aria-hidden="true"]')).toBeInTheDocument(); + const settings = canvas.getByRole("link", { name: "Settings" }); + await expect(settings).toHaveAttribute("href", "/tangled.org/settings"); + await expect(settings).toHaveClass("bg-transparent", "border-transparent"); + await userEvent.clear(search); + await userEvent.type(search, "microcosm.blue"); + const member = canvas.getByText("Member", { exact: true }); + await expect(member).toHaveClass("bg-background-inset"); + await expect(member.querySelector('svg[aria-hidden="true"]')).toBeInTheDocument(); + await expect(canvas.queryByRole("link", { name: "Settings" })).not.toBeInTheDocument(); + await userEvent.click( + canvas.getByRole("button", { name: "More actions for microcosm.blue" }) + ); + const leave = await canvas.findByRole("menuitem", { name: "Leave" }); + await expect(leave).toHaveClass("text-foreground-danger"); + await userEvent.click(leave); + const dialog = within(document.body).getByRole("dialog"); + await waitFor(() => expect(dialog).toBeVisible()); + await expect(within(dialog).getByText("Leave organization?")).toBeInTheDocument(); + await expect( + within(dialog).getByText(/You will lose access to the repositories/) + ).toBeInTheDocument(); + await userEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + await waitFor(() => + expect(within(document.body).queryByRole("dialog")).not.toBeInTheDocument() + ); + }} +/> + + { + await waitFor(async () => + expect(canvas.getByText("You are not part of any organization yet")).toBeVisible() + ); + }} +/> diff --git a/web/src/lib/components/settings/tabs/OrganizationsTab.svelte b/web/src/lib/components/settings/tabs/OrganizationsTab.svelte index 917deec18..219092e06 100644 --- a/web/src/lib/components/settings/tabs/OrganizationsTab.svelte +++ b/web/src/lib/components/settings/tabs/OrganizationsTab.svelte @@ -1,27 +1,58 @@ {/snippet} {#snippet end()} @@ -61,7 +104,12 @@ {/snippet} - {#if organizations.length === 0} + + + + {#if loaded.loading} + + {:else if loaded.data?.length === 0} {:else if filtered.length === 0} @@ -70,7 +118,12 @@ {#each filtered as organization (organization.did)} {#snippet tags()} - + {organization.role === "owner" ? "Owner" : "Member"} {/snippet} @@ -83,15 +136,35 @@ {/snippet} {#if organization.role === "owner"} {/if} - + + confirmLeave(organization)} + > + Leave + + {/each} {/if} + + -- 2.51.2