From ecc9bd7d6545e2b03d1a0d09d2b39f440f7e24d5 Mon Sep 17 00:00:00 2001 From: dawn Date: Fri, 11 Sep 2026 02:07:09 +0300 Subject: [PATCH] web: manage repo collaborators through the knot Signed-off-by: dawn --- web/src/lib/api/collaborators.test.ts | 94 +++++++++++++++ web/src/lib/api/collaborators.ts | 65 +++++++++++ web/src/lib/api/repoSettings.test.ts | 5 + web/src/lib/api/repoSettings.ts | 28 +++++ .../[repo]/settings/access/+page.svelte | 107 ++++++++++-------- .../[handle]/[repo]/settings/access/+page.ts | 15 +++ .../[repo]/settings/access/new/+page.svelte | 85 +++++++------- .../[repo]/settings/access/new/+page.ts | 6 + 8 files changed, 317 insertions(+), 88 deletions(-) create mode 100644 web/src/lib/api/collaborators.test.ts create mode 100644 web/src/lib/api/collaborators.ts create mode 100644 web/src/routes/[handle]/[repo]/settings/access/+page.ts create mode 100644 web/src/routes/[handle]/[repo]/settings/access/new/+page.ts diff --git a/web/src/lib/api/collaborators.test.ts b/web/src/lib/api/collaborators.test.ts new file mode 100644 index 000000000..319dca39e --- /dev/null +++ b/web/src/lib/api/collaborators.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it, vi } from "vitest"; +import { createBobbinClient } from "$lib/api/client"; +import { listCollaborators } from "$lib/api/collaborators"; + +const owner = "did:plc:owner"; +const repoDid = "did:plc:repo"; + +const bobbin = (pages: Record) => { + const fetch = vi.fn(async (input) => { + const asked = new URL(String(input)); + const page = pages[asked.pathname]; + return new Response(JSON.stringify(page ?? { error: "MethodNotImplemented" }), { + status: page ? 200 : 501, + headers: { "content-type": "application/json" } + }); + }); + return createBobbinClient({ serviceUrl: "https://bobbin.test", fetch }); +}; + +describe("listCollaborators", () => { + it("lists the owner first, then accepted collaborators, then open invites", async () => { + const ctx = bobbin({ + "/xrpc/sh.tangled.repo.listCollaborators": { + items: [ + { + subject: "did:plc:friend", + addedBy: owner, + createdAt: "a", + effectiveSince: "b" + }, + { subject: owner, addedBy: owner, createdAt: "a", effectiveSince: "b" } + ] + }, + "/xrpc/sh.tangled.repo.listCollaboratorInvites": { + items: [ + { subject: "did:plc:pending", addedBy: owner, createdAt: "c" }, + { subject: "did:plc:friend", addedBy: owner, createdAt: "c" } + ] + }, + "/xrpc/blue.microcosm.identity.resolveMiniDoc": { did: "x", handle: "someone.test" } + }); + + await expect(listCollaborators(ctx, { ownerDid: owner, repoDid })).resolves.toEqual([ + { did: owner, handle: "someone.test", standing: "owner", since: undefined }, + { did: "did:plc:friend", handle: "someone.test", standing: "collaborator", since: "b" }, + { did: "did:plc:pending", handle: "someone.test", standing: "invited", since: "c" } + ]); + }); + + it("falls back to the did when the handle will not resolve", async () => { + const unresolvable = "did:plc:nohandle"; + const ctx = bobbin({ + "/xrpc/sh.tangled.repo.listCollaborators": { items: [] }, + "/xrpc/sh.tangled.repo.listCollaboratorInvites": { items: [] } + }); + + await expect(listCollaborators(ctx, { ownerDid: unresolvable, repoDid })).resolves.toEqual([ + { did: unresolvable, handle: unresolvable, standing: "owner", since: undefined } + ]); + }); + + it("shows just the owner for a repo without a DID", async () => { + const solo = "did:plc:solo"; + const ctx = bobbin({ + "/xrpc/blue.microcosm.identity.resolveMiniDoc": { did: solo, handle: "solo.test" } + }); + + await expect(listCollaborators(ctx, { ownerDid: solo })).resolves.toEqual([ + { did: solo, handle: "solo.test", standing: "owner", since: undefined } + ]); + }); + + it("keeps the roster when the knot has no invites endpoint", async () => { + const ctx = bobbin({ + "/xrpc/sh.tangled.repo.listCollaborators": { + items: [ + { + subject: "did:plc:friend", + addedBy: owner, + createdAt: "a", + effectiveSince: "b" + } + ] + }, + "/xrpc/blue.microcosm.identity.resolveMiniDoc": { did: "x", handle: "someone.test" } + }); + + await expect( + listCollaborators(ctx, { ownerDid: owner, repoDid }).then((rows) => + rows.map((row) => row.standing) + ) + ).resolves.toEqual(["owner", "collaborator"]); + }); +}); diff --git a/web/src/lib/api/collaborators.ts b/web/src/lib/api/collaborators.ts new file mode 100644 index 000000000..df5792c15 --- /dev/null +++ b/web/src/lib/api/collaborators.ts @@ -0,0 +1,65 @@ +import type { Did } from "@atcute/lexicons/syntax"; +import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; +import { IdentityCache } from "$lib/api/identity"; +import { collect } from "$lib/api/pagination"; + +export type CollaboratorStanding = "owner" | "collaborator" | "invited"; + +export interface CollaboratorView { + did: string; + handle: string; + standing: CollaboratorStanding; + since?: string; +} + +const MAX_COLLABORATORS = 200; + +export const listCollaborators = async ( + ctx: BobbinContext, + repo: { ownerDid: string; repoDid?: string }, + init?: XrpcRequestInit +): Promise => { + const identities = new IdentityCache(ctx); + const rows: Omit[] = [{ did: repo.ownerDid, standing: "owner" }]; + + if (repo.repoDid) { + const subject = repo.repoDid as Did; + const page = { ...init, max: MAX_COLLABORATORS }; + const [accepted, invited] = await Promise.all([ + collect(ctx, "sh.tangled.repo.listCollaborators", { subject, limit: 100 }, page), + collect( + ctx, + "sh.tangled.repo.listCollaboratorInvites", + { subject, limit: 100 }, + page + ) + // fail open; knots and viewers without invite support reject the lookup + .catch(() => []) + ]); + const seen = new Set([repo.ownerDid]); + for (const item of accepted) { + if (seen.has(item.subject)) continue; + seen.add(item.subject); + rows.push({ + did: item.subject, + standing: "collaborator", + since: item.effectiveSince + }); + } + for (const item of invited) { + if (seen.has(item.subject)) continue; + seen.add(item.subject); + rows.push({ did: item.subject, standing: "invited", since: item.createdAt }); + } + } + + const named: CollaboratorView[] = []; + for (const row of rows) { + const handle = await identities + .resolve(row.did, init) + .then((doc) => doc.handle) + .catch(() => row.did); + named.push({ ...row, handle }); + } + return named; +}; diff --git a/web/src/lib/api/repoSettings.test.ts b/web/src/lib/api/repoSettings.test.ts index 6242e9d85..c0d83bf22 100644 --- a/web/src/lib/api/repoSettings.test.ts +++ b/web/src/lib/api/repoSettings.test.ts @@ -83,6 +83,11 @@ describe("knot procedures", () => { expect(call.mock.calls.at(-1)?.[1]).toEqual({ input: { repo: "did:plc:repo", defaultBranch: "trunk" } }); + + await api.addCollaborator(agent, "knot.test", "did:plc:repo", "did:plc:friend"); + expect(call.mock.calls.at(-1)?.[1]).toEqual({ + input: { repo: "did:plc:repo", subject: "did:plc:friend" } + }); }); }); diff --git a/web/src/lib/api/repoSettings.ts b/web/src/lib/api/repoSettings.ts index 874e90d1c..21985818b 100644 --- a/web/src/lib/api/repoSettings.ts +++ b/web/src/lib/api/repoSettings.ts @@ -4,7 +4,9 @@ import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { awaitDeletedRecord, awaitIndexedRecord } from "$lib/api/awaitRecord"; import { serviceClient } from "$lib/api/_request"; import { createBobbinClient } from "$lib/api/client"; +import { mainSchema as addCollaboratorSchema } from "$lib/api/lexicons/types/sh/tangled/repo/addCollaborator"; import { mainSchema as deleteRepoSchema } from "$lib/api/lexicons/types/sh/tangled/repo/delete"; +import { mainSchema as removeCollaboratorSchema } from "$lib/api/lexicons/types/sh/tangled/repo/removeCollaborator"; import { mainSchema as setDefaultBranchSchema } from "$lib/api/lexicons/types/sh/tangled/repo/setDefaultBranch"; import type { RecordView, RepoRecord } from "$lib/api/records"; import { rkeyFromUri } from "$lib/api/uri"; @@ -61,6 +63,32 @@ export const setDefaultBranch = async ( ); }; +export const addCollaborator = async ( + agent: OAuthUserAgent, + knot: string, + repoDid: string, + subject: string +): Promise => { + await ok( + serviceClient(agent, knot).xrpc.call(addCollaboratorSchema, { + input: { repo: repoDid as Did, subject: subject as Did } + }) + ); +}; + +export const removeCollaborator = async ( + agent: OAuthUserAgent, + knot: string, + repoDid: string, + subject: string +): Promise => { + await ok( + serviceClient(agent, knot).xrpc.call(removeCollaboratorSchema, { + input: { repo: repoDid as Did, subject: subject as Did } + }) + ); +}; + export interface RepoLocation { uri: string; knot: string; diff --git a/web/src/routes/[handle]/[repo]/settings/access/+page.svelte b/web/src/routes/[handle]/[repo]/settings/access/+page.svelte index c087a2c18..461d2f85c 100644 --- a/web/src/routes/[handle]/[repo]/settings/access/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/access/+page.svelte @@ -1,8 +1,15 @@ - - {#snippet skeleton()} - + + + + + + {#snippet action()} + {/snippet} - - {@const repo = await data.repo} - {@const base = `/${repo.ownerHandle}/${repo.name}/settings`} - - - {#snippet action()} - + + {#snippet skeleton()} + {/snippet} - + {@const collaborators = await data.collaborators} - {#each collaborators as person (person.handle)} + {#each collaborators as person (person.did)} {#snippet label()} @@ -71,18 +72,28 @@ {person.handle} - {person.role} + {#if person.standing === "invited"} + {ROLE[person.standing]} + {:else} + + {ROLE[person.standing]} + + {/if} {/snippet} - {#if !person.owner} - {/if} {/each} - - + + diff --git a/web/src/routes/[handle]/[repo]/settings/access/+page.ts b/web/src/routes/[handle]/[repo]/settings/access/+page.ts new file mode 100644 index 000000000..02b4a85ba --- /dev/null +++ b/web/src/routes/[handle]/[repo]/settings/access/+page.ts @@ -0,0 +1,15 @@ +import { requireRepoAdmin } from "$lib/api/access"; +import { createBobbinClient } from "$lib/api/client"; +import { listCollaborators } from "$lib/api/collaborators"; +import { stream } from "$lib/api/load"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + requireRepoAdmin(parent.access); + const ctx = createBobbinClient({ + serviceUrl: parent.publicConfig.bobbinUrl, + fetch: event.fetch + }); + return { collaborators: stream(listCollaborators(ctx, parent.repo)) }; +}; diff --git a/web/src/routes/[handle]/[repo]/settings/access/new/+page.svelte b/web/src/routes/[handle]/[repo]/settings/access/new/+page.svelte index d6ad2e458..7c2c8ad58 100644 --- a/web/src/routes/[handle]/[repo]/settings/access/new/+page.svelte +++ b/web/src/routes/[handle]/[repo]/settings/access/new/+page.svelte @@ -1,11 +1,13 @@ - - {#snippet skeleton()} - - {/snippet} - - {@const repo = await data.repo} - {@const back = `/${repo.ownerHandle}/${repo.name}/settings/access`} - - - - - - + + + + + + + + - - - - - - + + + + + diff --git a/web/src/routes/[handle]/[repo]/settings/access/new/+page.ts b/web/src/routes/[handle]/[repo]/settings/access/new/+page.ts new file mode 100644 index 000000000..a308e5542 --- /dev/null +++ b/web/src/routes/[handle]/[repo]/settings/access/new/+page.ts @@ -0,0 +1,6 @@ +import { requireRepoAdmin } from "$lib/api/access"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async (event) => { + requireRepoAdmin((await event.parent()).access); +}; -- 2.51.2