diff --git a/web/src/lib/api/orgMembers.test.ts b/web/src/lib/api/orgMembers.test.ts new file mode 100644 index 000000000..c2269b93c --- /dev/null +++ b/web/src/lib/api/orgMembers.test.ts @@ -0,0 +1,119 @@ +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 { + inviteOrganizationMembers, + listOrganizationMembers, + removeOrganizationMember +} from "$lib/api/orgMembers"; +import { + addController, + getScopePresets, + listControllers, + removeController +} from "$lib/api/tranquil"; + +vi.mock("$lib/api/actAs", () => ({ getOrCreateDelegatedSession: vi.fn() })); +vi.mock("$lib/api/tranquil", () => ({ + addController: vi.fn(), + getScopePresets: vi.fn(), + listControllers: vi.fn(), + removeController: vi.fn() +})); + +const OWNER_SCOPES = + "atproto repo:* blob:*/* rpc:* identity:* account:*?action=manage transition:generic"; +const EDITOR_SCOPES = "atproto repo:*?action=create repo:*?action=update blob:*/* rpc:*"; + +const controller = { sub: "did:plc:controller" as Did } as OAuthUserAgent; +const orgAgent = { sub: "did:plc:org" as Did } as OAuthUserAgent; +const orgDid = "did:plc:org" as Did; +const aliceDid = "did:plc:alice" as Did; +const bobDid = "did:plc:bob" as Did; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getOrCreateDelegatedSession).mockResolvedValue(orgAgent); + vi.mocked(getScopePresets).mockResolvedValue([ + { name: "owner", label: "Owner", description: "", scopes: OWNER_SCOPES }, + { name: "editor", label: "Editor", description: "", scopes: EDITOR_SCOPES } + ]); +}); + +describe("listOrganizationMembers", () => { + it("lists the active controllers of the organization with their roles", async () => { + vi.mocked(listControllers).mockResolvedValue([ + { + did: aliceDid, + handle: "alice.test", + grantedScopes: OWNER_SCOPES, + grantedAt: "2025-01-14T09:00:00.000Z", + isActive: true, + isLocal: true + }, + { + did: bobDid, + grantedScopes: EDITOR_SCOPES, + grantedAt: "2025-03-02T09:00:00.000Z", + isActive: true, + isLocal: false + }, + { + did: "did:plc:gone" as Did, + grantedScopes: EDITOR_SCOPES, + grantedAt: "2025-03-03T09:00:00.000Z", + isActive: false, + isLocal: false + } + ]); + + const members = await listOrganizationMembers(controller, orgDid); + + expect(getOrCreateDelegatedSession).toHaveBeenCalledWith(controller, orgDid); + expect(listControllers).toHaveBeenCalledWith(orgAgent); + expect(members).toEqual([ + { + did: aliceDid, + handle: "alice.test", + role: "owner", + grantedAt: "2025-01-14T09:00:00.000Z" + }, + { + did: bobDid, + handle: undefined, + role: "member", + grantedAt: "2025-03-02T09:00:00.000Z" + } + ]); + }); +}); + +describe("inviteOrganizationMembers", () => { + it("adds every invitee as a controller with the editor preset", async () => { + await inviteOrganizationMembers(controller, orgDid, [aliceDid, bobDid]); + + expect(getScopePresets).toHaveBeenCalledWith(orgAgent); + expect(vi.mocked(addController).mock.calls).toEqual([ + [orgAgent, { controllerDid: aliceDid, grantedScopes: EDITOR_SCOPES }], + [orgAgent, { controllerDid: bobDid, grantedScopes: EDITOR_SCOPES }] + ]); + }); + + it("refuses when the PDS has no editor preset", async () => { + vi.mocked(getScopePresets).mockResolvedValue([]); + + await expect(inviteOrganizationMembers(controller, orgDid, [aliceDid])).rejects.toThrow( + /editor role/ + ); + expect(addController).not.toHaveBeenCalled(); + }); +}); + +describe("removeOrganizationMember", () => { + it("removes the member's grant as the organization", async () => { + await removeOrganizationMember(controller, orgDid, bobDid); + + expect(removeController).toHaveBeenCalledWith(orgAgent, bobDid); + }); +}); diff --git a/web/src/lib/api/orgMembers.ts b/web/src/lib/api/orgMembers.ts new file mode 100644 index 000000000..61b92411f --- /dev/null +++ b/web/src/lib/api/orgMembers.ts @@ -0,0 +1,55 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { getOrCreateDelegatedSession } from "$lib/api/actAs"; +import { organizationRole, type OrganizationRole } from "$lib/api/organizations"; +import { + addController, + getScopePresets, + listControllers, + removeController +} from "$lib/api/tranquil"; + +export interface OrganizationMember { + did: Did; + handle?: string; + role: OrganizationRole; + grantedAt: string; +} + +export const listOrganizationMembers = async ( + controllerAgent: OAuthUserAgent, + orgDid: Did +): Promise => { + const orgAgent = await getOrCreateDelegatedSession(controllerAgent, orgDid); + const controllers = await listControllers(orgAgent); + return controllers + .filter((controller) => controller.isActive) + .map((controller) => ({ + did: controller.did, + handle: controller.handle, + role: organizationRole(controller.grantedScopes), + grantedAt: controller.grantedAt + })); +}; + +export const inviteOrganizationMembers = async ( + controllerAgent: OAuthUserAgent, + orgDid: Did, + dids: readonly Did[] +): Promise => { + const orgAgent = await getOrCreateDelegatedSession(controllerAgent, orgDid); + const editor = (await getScopePresets(orgAgent)).find((preset) => preset.name === "editor"); + if (!editor) throw new Error("The organization's PDS does not offer an editor role."); + for (const did of dids) { + await addController(orgAgent, { controllerDid: did, grantedScopes: editor.scopes }); + } +}; + +export const removeOrganizationMember = async ( + controllerAgent: OAuthUserAgent, + orgDid: Did, + memberDid: Did +): Promise => { + const orgAgent = await getOrCreateDelegatedSession(controllerAgent, orgDid); + await removeController(orgAgent, memberDid); +}; diff --git a/web/src/lib/api/organizations.ts b/web/src/lib/api/organizations.ts index 13d0c69da..525c6680e 100644 --- a/web/src/lib/api/organizations.ts +++ b/web/src/lib/api/organizations.ts @@ -21,6 +21,9 @@ export interface UserOrganization { const MANAGE_ACCOUNT_SCOPE = "account:*?action=manage"; +export const organizationRole = (grantedScopes: string): OrganizationRole => + grantedScopes.split(" ").includes(MANAGE_ACCOUNT_SCOPE) ? "owner" : "member"; + const controlledKey = (agent: OAuthUserAgent, serviceDid: string) => `controlled:${agent.sub}:${serviceDid}`; @@ -59,9 +62,7 @@ export const listUserOrganizations = async ( .map((account) => ({ did: account.did, handle: account.handle ?? account.did, - role: account.grantedScopes.split(" ").includes(MANAGE_ACCOUNT_SCOPE) - ? "owner" - : "member", + role: organizationRole(account.grantedScopes), joinedAt: account.grantedAt })); }; diff --git a/web/src/lib/components/organization/MemberPicker.stories.svelte b/web/src/lib/components/organization/MemberPicker.stories.svelte deleted file mode 100644 index 59c6ab47b..000000000 --- a/web/src/lib/components/organization/MemberPicker.stories.svelte +++ /dev/null @@ -1,109 +0,0 @@ - - - { - const list = within(canvas.getByRole("list")); - const boxes = list.getAllByRole("checkbox"); - await userEvent.click(list.getByText("oppi.tld")); - await expect(boxes[0]).toBeChecked(); - await userEvent.keyboard("{Shift>}"); - await userEvent.click(list.getByText("akshay.tld")); - await userEvent.keyboard("{/Shift}"); - for (const box of boxes.slice(0, 3)) await expect(box).toBeChecked(); - await expect(boxes[3]).not.toBeChecked(); - await userEvent.keyboard("{Control>}"); - await userEvent.click(list.getByText("icyphox.sh")); - await userEvent.keyboard("{/Control}"); - await expect(boxes[0]).toBeChecked(); - await expect(boxes[1]).not.toBeChecked(); - await expect(boxes[2]).toBeChecked(); - await userEvent.keyboard("{Meta>}"); - await userEvent.click(list.getByText("natalie.tld")); - await userEvent.keyboard("{/Meta}"); - await expect(boxes[0]).toBeChecked(); - await expect(boxes[2]).toBeChecked(); - await expect(boxes[3]).toBeChecked(); - await userEvent.click(list.getByText("akshay.tld")); - await expect(boxes[0]).not.toBeChecked(); - await expect(boxes[1]).not.toBeChecked(); - await expect(boxes[2]).toBeChecked(); - await expect(boxes[3]).not.toBeChecked(); - }} -/> - - { - const boxes = canvas.getAllByRole("checkbox"); - await userEvent.click(boxes[0]); - await userEvent.click(boxes[0]); - await expect(boxes[0]).toBeChecked(); - await userEvent.keyboard("{Shift>}"); - await userEvent.click(boxes[2]); - await userEvent.keyboard("{/Shift}"); - for (const box of boxes.slice(0, 3)) await expect(box).toBeChecked(); - await userEvent.keyboard("{Control>}"); - await userEvent.click(boxes[1]); - await expect(boxes[1]).not.toBeChecked(); - await userEvent.click(boxes[1]); - await expect(boxes[1]).toBeChecked(); - await userEvent.keyboard("{/Control}"); - await userEvent.tab(); - await expect(boxes[2]).toHaveFocus(); - await userEvent.keyboard(" "); - await expect(boxes[0]).not.toBeChecked(); - await expect(boxes[1]).not.toBeChecked(); - await expect(boxes[2]).toBeChecked(); - await userEvent.keyboard("{Control>} {/Control}"); - await expect(boxes[2]).not.toBeChecked(); - await userEvent.keyboard(" "); - await expect(boxes[2]).toBeChecked(); - }} -/> - - { - const list = within(canvas.getByRole("list")); - const search = canvas.getByRole("searchbox"); - await userEvent.click(list.getByText("akshay.tld")); - await userEvent.type(search, ".tld"); - await expect(list.getByRole("checkbox", { name: "akshay.tld" })).toBeChecked(); - await userEvent.keyboard("{Shift>}"); - await userEvent.click(list.getByText("oppi.tld")); - await userEvent.keyboard("{/Shift}"); - await expect(list.getByRole("checkbox", { name: "oppi.tld" })).toBeChecked(); - await expect(list.getByRole("checkbox", { name: "akshay.tld" })).toBeChecked(); - await expect(list.getByRole("checkbox", { name: "natalie.tld" })).not.toBeChecked(); - await userEvent.clear(search); - await expect(list.getByRole("checkbox", { name: "icyphox.sh" })).not.toBeChecked(); - await userEvent.type(search, "icyphox"); - await userEvent.keyboard("{Shift>}"); - await userEvent.click(list.getByText("icyphox.sh")); - await userEvent.keyboard("{/Shift}"); - await expect(list.getByRole("checkbox", { name: "icyphox.sh" })).toBeChecked(); - await expect(canvas.getByRole("button", { name: "Invite (3) members" })).toBeVisible(); - await userEvent.clear(search); - await userEvent.keyboard("{Shift>}"); - await userEvent.click(list.getByText("akshay.tld")); - await userEvent.keyboard("{/Shift}"); - await expect(list.getByRole("checkbox", { name: "oppi.tld" })).not.toBeChecked(); - await expect(list.getByRole("checkbox", { name: "icyphox.sh" })).toBeChecked(); - await expect(list.getByRole("checkbox", { name: "akshay.tld" })).toBeChecked(); - await expect(list.getByRole("checkbox", { name: "natalie.tld" })).not.toBeChecked(); - await userEvent.click(canvas.getByRole("button", { name: "Invite (2) members" })); - await expect(args.onInvite).toHaveBeenCalledExactlyOnceWith(memberCandidates.slice(1, 3)); - }} -/> diff --git a/web/src/lib/components/organization/MemberPicker.svelte b/web/src/lib/components/organization/MemberPicker.svelte deleted file mode 100644 index b64a11e38..000000000 --- a/web/src/lib/components/organization/MemberPicker.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - -
- - -
- {#if results.length === 0} - - {:else} -
    - {#each results as person, index (person.did)} - {@const isSelected = selected.includes(person.did)} - {@const startsRange = isSelected && !selected.includes(results[index - 1]?.did)} - {@const endsRange = isSelected && !selected.includes(results[index + 1]?.did)} -
  • - {#if isSelected} -
    - {/if} - - - - {reasonLabel[person.reason]} - - -
  • - {/each} -
- {/if} - - {#if pending.length > 0} -
-
    - {#each pending as person (person.did)} -
  • - -
  • - {/each} -
- -
- {/if} -
-
diff --git a/web/src/lib/components/profile/organization/mock.ts b/web/src/lib/components/profile/organization/mock.ts deleted file mode 100644 index 55fb745e2..000000000 --- a/web/src/lib/components/profile/organization/mock.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { PersonData, RepoCardData } from "$lib/components/profile/types"; - -export const organizationMembers: PersonData[] = Array.from({ length: 9 }, (_, index) => ({ - did: `did:plc:organization-member-${index + 1}`, - handle: index === 0 ? "user.tld" : `member-${index + 1}.tld`, - description: "Organization member", - followers: 0, - following: 0, - viewerFollowRkey: null, - isSelf: false -})); - -export interface MemberCandidate { - did: string; - handle: string; - reason: "vouched" | "interacted"; -} - -export const memberCandidates: MemberCandidate[] = [ - { did: "did:plc:candidate-1", handle: "oppi.tld", reason: "vouched" }, - { did: "did:plc:candidate-2", handle: "icyphox.sh", reason: "vouched" }, - { did: "did:plc:candidate-3", handle: "akshay.tld", reason: "interacted" }, - { did: "did:plc:candidate-4", handle: "natalie.tld", reason: "interacted" }, - { did: "did:plc:candidate-5", handle: "jules.tld", reason: "interacted" }, - { did: "did:plc:candidate-6", handle: "rafael.tld", reason: "interacted" }, - { did: "did:plc:candidate-7", handle: "sasha.tld", reason: "interacted" }, - { did: "did:plc:candidate-8", handle: "wren.tld", reason: "interacted" } -]; - -export const organizationRepoSuggestions: RepoCardData[] = [ - { - name: "core", - repoDid: "did:plc:organization-repo-core", - ownerHandle: "user.tld", - description: "The main monorepo.", - createdAt: "2024-11-04T09:12:00.000Z", - language: "Go", - stars: 128, - forks: 14, - issues: 23, - pulls: 7 - }, - { - name: "docs", - repoDid: "did:plc:organization-repo-docs", - ownerHandle: "user.tld", - description: "User and operator documentation.", - createdAt: "2025-02-18T15:40:00.000Z", - language: "JavaScript", - stars: 19, - forks: 3, - issues: 5, - pulls: 2 - }, - { - name: "design", - repoDid: "did:plc:organization-repo-design", - ownerHandle: "user.tld", - description: "Design system and brand assets.", - createdAt: "2025-06-02T11:05:00.000Z", - stars: 8, - forks: 1, - issues: 4, - pulls: 1 - } -]; diff --git a/web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte b/web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte index 06bf0336b..fa36db6e4 100644 --- a/web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte +++ b/web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte @@ -1,6 +1,6 @@ + + { + const input = canvas.getByRole("textbox", { name: "Handles" }); + const invite = canvas.getByRole("button", { name: "Invite 0 members" }); + await expect(invite).toBeDisabled(); + await expect(canvas.queryByRole("option")).not.toBeInTheDocument(); + await expect(canvas.getByRole("link", { name: "Members" })).toHaveAttribute( + "href", + "/tangled.org?tab=settings§ion=members" + ); + + await userEvent.type(input, "opp"); + const option = await canvas.findByRole("option", { name: /oppi\.tld/ }); + await expect( + canvas.queryByRole("option", { name: /natalie\.tld/ }) + ).not.toBeInTheDocument(); + await userEvent.click(option); + await waitFor(() => + expect(canvas.getByRole("button", { name: "Remove oppi.tld" })).toBeVisible() + ); + await expect(canvas.getByRole("button", { name: "Invite 1 member" })).toBeEnabled(); + + await userEvent.type(input, "icyphox.sh{Enter}"); + await waitFor(() => + expect(canvas.getByRole("button", { name: "Remove icyphox.sh" })).toBeVisible() + ); + await expect(canvas.getByRole("button", { name: "Invite 2 members" })).toBeEnabled(); + + await userEvent.type(input, "not a handle{Enter}"); + await waitFor(() => expect(canvas.getByText("Not a valid handle")).toBeVisible()); + await expect(canvas.getByRole("button", { name: "Invite 2 members" })).toBeEnabled(); + + await userEvent.clear(input); + await userEvent.type(input, "alice.pds.tngl.boltless.dev{Enter}"); + await waitFor(() => expect(canvas.getByText("You are already a member")).toBeVisible()); + await expect(canvas.getByRole("button", { name: "Invite 2 members" })).toBeEnabled(); + await userEvent.clear(input); + + await userEvent.click(canvas.getByRole("button", { name: "Remove oppi.tld" })); + await waitFor(() => + expect(canvas.getByRole("button", { name: "Invite 1 member" })).toBeEnabled() + ); + }} +/> diff --git a/web/src/routes/[handle]/settings/members/InviteMembers.svelte b/web/src/routes/[handle]/settings/members/InviteMembers.svelte index 809cfa6a8..185c7ebca 100644 --- a/web/src/routes/[handle]/settings/members/InviteMembers.svelte +++ b/web/src/routes/[handle]/settings/members/InviteMembers.svelte @@ -1,18 +1,103 @@ - void goto(resolve(backHref as "/"))} /> + + {#snippet tag(t)} + + {/snippet} + {#snippet suggestion(t)} + + {/snippet} + + + + + + + + diff --git a/web/src/routes/[handle]/settings/members/MembersTab.stories.svelte b/web/src/routes/[handle]/settings/members/MembersTab.stories.svelte index 49e7f5b98..5c000d0c6 100644 --- a/web/src/routes/[handle]/settings/members/MembersTab.stories.svelte +++ b/web/src/routes/[handle]/settings/members/MembersTab.stories.svelte @@ -2,39 +2,23 @@ import { defineMeta } from "@storybook/addon-svelte-csf"; import { expect } from "storybook/test"; import MembersTab from "$routes/[handle]/settings/members/MembersTab.svelte"; - import { organizationMembers } from "$lib/components/profile/organization/mock"; const { Story } = defineMeta({ title: "Settings/MembersTab", component: MembersTab, - args: { handle: "tangled.org" } + args: { handle: "tangled.org", did: "did:plc:organization-tangled" }, + parameters: { auth: false } }); { - const search = canvas.getByRole("searchbox", { name: "Search members" }); - await expect(canvas.getAllByRole("button", { name: /^More actions for / })).toHaveLength( - organizationMembers.length - ); + name="Signed out" + play={async ({ canvas }) => { + await expect(canvas.getByRole("heading", { name: "Members" })).toBeVisible(); await expect(canvas.getByRole("link", { name: "Add member" })).toHaveAttribute( "href", "/tangled.org?tab=settings§ion=invite" ); - await userEvent.type(search, " UsEr.TLD "); - await expect(canvas.getByRole("link", { name: "user.tld" })).toBeVisible(); - await expect(canvas.getAllByRole("button", { name: /^More actions for / })).toHaveLength(1); - await userEvent.clear(search); - await userEvent.type(search, "no-such-member"); - await expect(canvas.getByText("No member matches your search")).toBeVisible(); - await expect( - canvas.queryByRole("button", { name: /^More actions for / }) - ).not.toBeInTheDocument(); - await userEvent.clear(search); - await expect(canvas.queryByText("No member matches your search")).not.toBeInTheDocument(); - for (const member of organizationMembers) { - await expect(canvas.getByRole("link", { name: member.handle })).toBeVisible(); - } + await expect(await canvas.findByText("No members yet")).toBeVisible(); }} /> diff --git a/web/src/routes/[handle]/settings/members/MembersTab.svelte b/web/src/routes/[handle]/settings/members/MembersTab.svelte index 99bb05a3b..515c04463 100644 --- a/web/src/routes/[handle]/settings/members/MembersTab.svelte +++ b/web/src/routes/[handle]/settings/members/MembersTab.svelte @@ -1,8 +1,21 @@ @@ -66,7 +112,13 @@ {/snippet} - {#if members.length === 0} + + + + + {#if members.loading} + + {:else if members.data?.length === 0} {:else if filtered.length === 0} @@ -75,13 +127,31 @@ {#each filtered as member (member.did)} {#snippet label()} - + + + + {member.role === "owner" ? "Owner" : "Member"} + + {/snippet} - - confirmRemove(member)}> - Remove - - + {#if member.did === auth.currentDid} + {#if member.role === "member"} + + {/if} + {:else if viewerIsOwner} + + confirmRemove(member)}> + Remove + + + {/if} {/each} @@ -92,8 +162,18 @@ bind:open={removeConfirmOpen} title="Remove member?" description={memberPendingRemoval - ? `${memberPendingRemoval.handle} will lose access to the repositories of this organization. An owner has to invite them back.` + ? `${nameOf(memberPendingRemoval)} will lose access to the repositories of this organization. An owner has to invite them back.` : undefined} confirmLabel="Remove" + loading={remove.loading} onconfirm={removeSelected} /> + +