From 894c9e598c082d348dbe8f7a856808188d97c438 Mon Sep 17 00:00:00 2001 From: oppiliappan Date: Wed, 9 Sep 2026 15:04:24 +0100 Subject: [PATCH] web: create repositories under an organization The creation form offers any organization the signed-in account controls, and repo creation drives both the PDS record and the knot call with that organization's agent. --- web/src/lib/api/repoCreate.test.ts | 36 +++++- .../components/repo/RepoCreationForm.svelte | 118 +++++++++++++++--- web/src/routes/repo/new/+page.svelte | 1 + 3 files changed, 138 insertions(+), 17 deletions(-) diff --git a/web/src/lib/api/repoCreate.test.ts b/web/src/lib/api/repoCreate.test.ts index 91d85a5eb..c7bbfde84 100644 --- a/web/src/lib/api/repoCreate.test.ts +++ b/web/src/lib/api/repoCreate.test.ts @@ -17,6 +17,7 @@ const load = async ({ createError, putError }: HarnessOptions = {}) => { const events: string[] = []; const records: unknown[] = []; const knotInputs: Record[] = []; + const knotAgents: OAuthUserAgent[] = []; const createRecord = vi.fn(async (_agent, _collection, record) => { events.push("record:create"); records.push(record); @@ -38,13 +39,20 @@ const load = async ({ createError, putError }: HarnessOptions = {}) => { vi.doMock("./write", () => ({ createRecord, putRecord, deleteRecord })); vi.doMock("./client", () => ({ - createBobbinClient: ({ serviceUrl }: { serviceUrl: string }) => ({ + createBobbinClient: ({ + serviceUrl, + agent: caller + }: { + serviceUrl: string; + agent?: OAuthUserAgent; + }) => ({ xrpc: { call: async (_schema: unknown, options: { input?: Record }) => { if (serviceUrl === "https://bobbin.test") { events.push("bobbin:await"); return { ok: true, data: { status: "indexed" } }; } + if (caller) knotAgents.push(caller); if (options.input) knotInputs.push(options.input); if (options.input && "repo" in options.input) { events.push("knot:delete"); @@ -63,6 +71,7 @@ const load = async ({ createError, putError }: HarnessOptions = {}) => { events, records, knotInputs, + knotAgents, createRecord, putRecord, deleteRecord @@ -80,6 +89,31 @@ const input = { }; describe("repository creation", () => { + it("uses the organization agent for the PDS record and knot creation", async () => { + const { api, createRecord, putRecord, knotAgents } = await load(); + const org = { sub: "did:plc:org" } as unknown as OAuthUserAgent; + const result = await api.createRepo(org, "https://bobbin.test", { + ...input, + ownerDid: "did:plc:org", + ownerHandle: "org.test" + }); + expect(createRecord.mock.calls[0][0]).toBe(org); + expect(putRecord.mock.calls[0][0]).toBe(org); + expect(knotAgents).toEqual([org]); + expect(result.ownerHandle).toBe("org.test"); + }); + it("rejects an organization owner paired with the controller's session", async () => { + const { api, createRecord } = await load(); + await expect( + api.createRepo(agent, "https://bobbin.test", { + ...input, + ownerDid: "did:plc:org", + ownerHandle: "org.test" + }) + ).rejects.toThrow("active account changed"); + expect(createRecord).not.toHaveBeenCalled(); + }); + it("announces the record before creating the knot repo, then commits the repo DID", async () => { const { api, events, records, putRecord } = await load(); diff --git a/web/src/lib/components/repo/RepoCreationForm.svelte b/web/src/lib/components/repo/RepoCreationForm.svelte index 33fb43d74..b61ba457e 100644 --- a/web/src/lib/components/repo/RepoCreationForm.svelte +++ b/web/src/lib/components/repo/RepoCreationForm.svelte @@ -26,15 +26,19 @@ import { goto } from "$app/navigation"; import { resolve } from "$app/paths"; import { page } from "$app/state"; + import type { Did } from "@atcute/lexicons/syntax"; import type { Component } from "svelte"; import { untrack } from "svelte"; import type { SvelteHTMLElements } from "svelte/elements"; import Plus from "$icon/plus"; import { createAction } from "$lib/action.svelte"; + import { getOrCreateDelegatedSession } from "$lib/api/actAs"; import { createBobbinClient } from "$lib/api/client"; + import { listDelegatedAccounts } from "$lib/api/delegation"; import { getPreferences, type Preferences } from "$lib/api/preferences"; import bonesRadioLoadingRow from "$lib/bones/bones-radio--loading-row.bones.json"; - import { getAuth } from "$lib/auth.svelte"; + import { getAuth, type AuthAccount } from "$lib/auth.svelte"; + import AccountSelector from "$lib/components/auth/AccountSelector.svelte"; import Avatar from "$lib/components/ui/Avatar.svelte"; import Bones from "$lib/components/ui/Bones.svelte"; import Button from "$lib/components/ui/Button.svelte"; @@ -51,13 +55,69 @@ pendingLabel: string; icon: Component; submit: RepoCreationSubmit; + delegationDid?: string; class?: string; } - let { initial, submitLabel, pendingLabel, icon, submit, class: className }: Props = $props(); + let { + initial, + submitLabel, + pendingLabel, + icon, + submit, + delegationDid, + class: className + }: Props = $props(); const auth = getAuth(); const user = $derived(auth.currentUser); + let organizations = $state([]); + let selectedOwnerDid = $state(""); + let ownersLoading = $state(false); + let ownersError = $state(null); + const owners = $derived([ + ...auth.accounts, + ...organizations.filter((org) => !auth.accounts.some((account) => account.did === org.did)) + ]); + const owner = $derived(owners.find((account) => account.did === selectedOwnerDid) ?? user); + $effect(() => { + const did = user?.did; + const serviceDid = delegationDid; + let current = true; + selectedOwnerDid = did ?? ""; + organizations = []; + ownersError = null; + ownersLoading = Boolean(did && serviceDid); + if (did && serviceDid) { + void auth + .agentFor(did, { relogin: false }) + .then((agent) => listDelegatedAccounts(agent, serviceDid)) + .then((accounts) => { + if (current) + organizations = accounts.map((account) => ({ + did: account.did, + handle: account.handle ?? account.did, + addedAt: Date.parse(account.grantedAt) / 1000 + })); + }) + .catch((cause) => { + if (current) + ownersError = `Could not load organizations: ${cause instanceof Error ? cause.message : String(cause)}`; + }) + .finally(() => { + if (current) ownersLoading = false; + }); + } + return () => { + current = false; + }; + }); + const agentForOwner = async (ownerDid: Did, options?: { relogin?: boolean }) => { + if (!user) throw new Error("Sign in to continue."); + if (auth.accounts.some((account) => account.did === ownerDid)) + return auth.agentFor(ownerDid, options); + return getOrCreateDelegatedSession(await auth.agentFor(user.did, options), ownerDid); + }; const showDefaultBranch = untrack(() => initial.defaultBranch !== undefined); let name = $state(untrack(() => initial.name)); let description = $state(untrack(() => initial.description)); @@ -69,21 +129,21 @@ let selectedKnot = $state(""); let selectedSpindle = $state(""); $effect(() => { - const did = auth.currentDid; + const did = owner?.did; let current = true; const loading: Promise = did - ? auth - .agentFor(did, { relogin: false }) + ? agentForOwner(did, { relogin: false }) .then((agent) => getPreferences(createBobbinClient({ serviceUrl: auth.bobbinUrl, agent })) ) .catch(() => NOTHING_SAVED) : Promise.resolve(NOTHING_SAVED); preferences = loading; - void loading.then(({ knots: saved }) => { + void loading.then((saved) => { if (!current) return; - const knots = makeKnotList(saved); + const knots = makeKnotList(saved.knots); if (!knots.includes(selectedKnot)) selectedKnot = knots[0] ?? ""; + if (!makeSpindleList(saved.spindles).includes(selectedSpindle)) selectedSpindle = ""; }); return () => { current = false; @@ -92,7 +152,12 @@ const action = createAction(async () => { if (!user) throw new Error("Sign in to continue."); - const agent = await auth.agentFor(user.did); + const controller = user; + const selectedOwner = owner; + if (!selectedOwner) throw new Error("Select a repository owner."); + const agent = await agentForOwner(selectedOwner.did); + if (auth.currentUser?.did !== controller.did) + throw new Error("The active account changed. Try again."); const repo = await submit( { name, @@ -101,7 +166,7 @@ ...(showDefaultBranch ? { defaultBranch: defaultBranch.trim() || "main" } : {}), ...(selectedSpindle ? { spindle: selectedSpindle } : {}) }, - { user, agent } + { user: selectedOwner, agent } ); await goto(resolve(`/${repo.ownerHandle}/${repo.name}` as "/")); }); @@ -139,12 +204,25 @@ Repository name
- + {#if owners.length > 1} + owner?.did ?? "", (did) => (selectedOwnerDid = did) + } + label="Repository owner" + disabled={!user || action.loading} + group="repo-creation" + class="min-w-0 md:mr-2 md:max-w-[55%]" + /> + {:else} + + {/if} 1 + ? "flex-1" + : "flex-1 md:rounded-l-none md:rounded-r"} />
+ {#if ownersLoading}

+ Loading organizations… +

{/if} +

Choose a unique, descriptive name. Use letters, numbers, periods, underscores, and hyphens. diff --git a/web/src/routes/repo/new/+page.svelte b/web/src/routes/repo/new/+page.svelte index 520733b54..f1665d3cc 100644 --- a/web/src/routes/repo/new/+page.svelte +++ b/web/src/routes/repo/new/+page.svelte @@ -34,6 +34,7 @@