diff --git a/web/src/lib/api/organizations.test.ts b/web/src/lib/api/organizations.test.ts index 6e878d286..44d695b80 100644 --- a/web/src/lib/api/organizations.test.ts +++ b/web/src/lib/api/organizations.test.ts @@ -4,14 +4,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getOrCreateDelegatedSession } from "$lib/api/actAs"; import { cached, invalidate } from "$lib/api/cache"; import type { BobbinContext } from "$lib/api/client"; -import { REPO_COUNT } from "$lib/api/descriptors"; -import { enrich, TYPE_COUNT } from "$lib/api/enrich"; +import { listDelegatedAccounts } from "$lib/api/delegation"; import { leaveOrganization, listUserOrganizations, viewerControlsOrganization } from "$lib/api/organizations"; -import { listDelegatedAccounts } from "$lib/api/delegation"; +import { getProfiles } from "$lib/api/records"; import { removeController } from "$lib/api/tranquil"; import { forgetOrgDid } from "$lib/auth/accounts"; @@ -19,14 +18,11 @@ vi.mock("$lib/api/actAs", () => ({ getOrCreateDelegatedSession: vi.fn() })); vi.mock("$lib/api/delegation", () => ({ listDelegatedAccounts: vi.fn() })); vi.mock("$lib/api/tranquil", () => ({ removeController: vi.fn() })); vi.mock("$lib/auth/accounts", () => ({ forgetOrgDid: vi.fn() })); +vi.mock("$lib/api/records", () => ({ getProfiles: vi.fn() })); vi.mock("$lib/api/cache", () => ({ cached: vi.fn((_key: string, _ttl: number, fn: () => Promise) => fn()), invalidate: 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"; @@ -41,10 +37,6 @@ 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(listDelegatedAccounts).mockResolvedValue([ @@ -61,55 +53,40 @@ beforeEach(() => { 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) } + vi.mocked(getProfiles).mockResolvedValue({ + items: [ + { + uri: profileUri(tangledDid), + value: { $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: true } + }, + { + uri: profileUri(microcosmDid), + value: { $type: "sh.tangled.actor.profile", bluesky: false, isOrganization: true } + } + ] } as never); }); describe("listUserOrganizations", () => { - it("joins the controlled accounts with their profiles and repo counts", async () => { + it("lists the controlled accounts with the viewer's role", async () => { const organizations = await listUserOrganizations(controller, ctx, tranquilDid); expect(listDelegatedAccounts).toHaveBeenCalledWith(controller, tranquilDid); - expect(vi.mocked(enrich).mock.calls[0][1]).toMatchObject({ - xrpc: "sh.tangled.actor.getProfiles", - params: { actors: [profileUri(tangledDid), profileUri(microcosmDid)] } - }); + expect(getProfiles).toHaveBeenCalledWith( + ctx, + [profileUri(tangledDid), profileUri(microcosmDid)], + undefined + ); 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" } @@ -117,20 +94,17 @@ describe("listUserOrganizations", () => { }); 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 - } + vi.mocked(getProfiles).mockResolvedValue({ + 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, tranquilDid); @@ -139,19 +113,19 @@ describe("listUserOrganizations", () => { }); it("still lists the organizations when the appview is unreachable", async () => { - vi.mocked(enrich).mockRejectedValue(new Error("bobbin is down")); + vi.mocked(getProfiles).mockRejectedValue(new Error("bobbin is down")); const organizations = await listUserOrganizations(controller, ctx, tranquilDid); expect(organizations).toHaveLength(2); - expect(organizations[0]).toMatchObject({ handle: "tangled.org", repos: 0, role: "owner" }); + expect(organizations[0]).toMatchObject({ handle: "tangled.org", role: "owner" }); }); it("does not reach for profiles when nothing is controlled", async () => { vi.mocked(listDelegatedAccounts).mockResolvedValue([]); await expect(listUserOrganizations(controller, ctx, tranquilDid)).resolves.toEqual([]); - expect(enrich).not.toHaveBeenCalled(); + expect(getProfiles).not.toHaveBeenCalled(); }); }); diff --git a/web/src/lib/api/organizations.ts b/web/src/lib/api/organizations.ts index 88a861cde..13d0c69da 100644 --- a/web/src/lib/api/organizations.ts +++ b/web/src/lib/api/organizations.ts @@ -3,11 +3,9 @@ import { deleteStoredSession, type OAuthUserAgent } from "@atcute/oauth-browser- import { getOrCreateDelegatedSession } from "$lib/api/actAs"; import { cached, invalidate } from "$lib/api/cache"; 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 { listDelegatedAccounts } from "$lib/api/delegation"; import { PROFILE_COLLECTION } from "$lib/api/profile"; +import { getProfiles, type ProfileRecord } from "$lib/api/records"; import { removeController } from "$lib/api/tranquil"; import { didFromUri } from "$lib/api/uri"; import { forgetOrgDid } from "$lib/auth/accounts"; @@ -17,8 +15,6 @@ export type OrganizationRole = "owner" | "member"; export interface UserOrganization { did: Did; handle: string; - description?: string; - repos: number; role: OrganizationRole; joinedAt: string; } @@ -33,33 +29,17 @@ const controlledAccounts = (agent: OAuthUserAgent, serviceDid: string) => listDelegatedAccounts(agent, serviceDid) ); -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>( +): Promise> => { + const list = await getProfiles( ctx, - { - xrpc: "sh.tangled.actor.getProfiles", - params: { actors: dids.map((did) => `at://${did}/${PROFILE_COLLECTION}/self`) }, - enrich: [target(REPO_COUNT, ["items[].uri"])] - }, + dids.map((did) => `at://${did}/${PROFILE_COLLECTION}/self`), 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 }; + return new Map(list.items.map((item) => [didFromUri(item.uri), item.value])); }; export const listUserOrganizations = async ( @@ -71,26 +51,19 @@ export const listUserOrganizations = async ( const accounts = await controlledAccounts(agent, serviceDid); 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 + const profiles = await fetchOrganizationProfiles(ctx, dids, init).catch( + () => new Map() ); 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: account.grantedScopes.split(" ").includes(MANAGE_ACCOUNT_SCOPE) - ? "owner" - : "member", - joinedAt: account.grantedAt - }; - }); + .map((account) => ({ + did: account.did, + handle: account.handle ?? account.did, + role: account.grantedScopes.split(" ").includes(MANAGE_ACCOUNT_SCOPE) + ? "owner" + : "member", + joinedAt: account.grantedAt + })); }; export const viewerControlsOrganization = async ( diff --git a/web/src/lib/components/profile/ProfileChrome.stories.svelte b/web/src/lib/components/profile/ProfileChrome.stories.svelte index d3be2397c..46e3fc18d 100644 --- a/web/src/lib/components/profile/ProfileChrome.stories.svelte +++ b/web/src/lib/components/profile/ProfileChrome.stories.svelte @@ -2,6 +2,7 @@ import { defineMeta, type StoryContext } from "@storybook/addon-svelte-csf"; import { expect } from "storybook/test"; import OrganizationOverviewTab from "$lib/components/profile/organization/OrganizationOverviewTab.svelte"; + import { clear as clearCache } from "$lib/api/cache"; import ProfileChrome from "$lib/components/profile/ProfileChrome.svelte"; import OverviewTab from "$lib/components/profile/tabs/OverviewTab.svelte"; import type { ProfileRecord } from "$lib/api/records"; @@ -92,11 +93,50 @@ const organizationOverview = async ({ canvas }: PlayContext) => { await expect(canvas.getByRole("heading", { name: identity.handle })).toBeVisible(); await expect(canvas.getByRole("link", { name: /Repositories/ })).toBeVisible(); - await expect(canvas.getByRole("link", { name: /Members/ })).toBeVisible(); - await expect(canvas.getByRole("link", { name: "Settings" })).toBeVisible(); + await expect(canvas.queryByRole("link", { name: /Members/ })).not.toBeInTheDocument(); await expect(canvas.getByRole("heading", { name: "Pinned repositories" })).toBeVisible(); }; - const { Story } = defineMeta({ title: "Profile/ProfileChrome" }); + const organizationAsVisitor = async (context: PlayContext) => { + await organizationOverview(context); + await expect( + context.canvas.queryByRole("link", { name: "Settings" }) + ).not.toBeInTheDocument(); + }; + const organizationAsController = async (context: PlayContext) => { + await organizationOverview(context); + const settings = await context.canvas.findByRole("link", { name: "Settings" }); + await expect(settings).toHaveAttribute("href", `/${identity.handle}?tab=settings`); + }; + + const tranquilDid = "did:web:tranquil.example"; + const mintServiceAuth = (pathname: string) => + pathname.startsWith("/xrpc/com.atproto.server.getServiceAuth") + ? Response.json({ token: "service-jwt" }) + : Response.json({ error: "NotFound" }, { status: 404 }); + const installFetchMock = () => { + clearCache(); + const original = window.fetch; + window.fetch = async (input, init) => { + const url = String(input instanceof Request ? input.url : input); + if (url === "https://tranquil.example/xrpc/_delegation.listControlledAccounts") + return Response.json({ + accounts: [ + { + did: identity.did, + handle: identity.handle, + grantedScopes: "atproto account:*?action=manage", + grantedAt: "2026-01-01T00:00:00.000Z" + } + ] + }); + return original(input, init); + }; + return () => { + window.fetch = original; + }; + }; + + const { Story } = defineMeta({ title: "Profile/ProfileChrome", beforeEach: installFetchMock }); {#snippet content()} @@ -119,7 +159,31 @@ /> - + + + + + diff --git a/web/src/lib/components/profile/ProfileChrome.svelte b/web/src/lib/components/profile/ProfileChrome.svelte index cddb51a3e..a39ba322b 100644 --- a/web/src/lib/components/profile/ProfileChrome.svelte +++ b/web/src/lib/components/profile/ProfileChrome.svelte @@ -21,6 +21,7 @@ viewerFollowRkey?: string | null; notJoined: boolean; bobbinUrl: string; + delegationDid?: string; activeTab: string; content: Snippet; } @@ -32,6 +33,7 @@ viewerFollowRkey, notJoined, bobbinUrl, + delegationDid, activeTab, content }: Props = $props(); @@ -87,6 +89,7 @@ {profile} {counts} {viewerFollowRkey} + {delegationDid} {activeTab} {content} /> diff --git a/web/src/lib/components/profile/ProfileShell.svelte b/web/src/lib/components/profile/ProfileShell.svelte index c6b301362..9c7c1a039 100644 --- a/web/src/lib/components/profile/ProfileShell.svelte +++ b/web/src/lib/components/profile/ProfileShell.svelte @@ -16,6 +16,7 @@ viewerFollowRkey?: string | null | Promise; notJoined: boolean | Promise; bobbinUrl: string; + delegationDid?: string; activeTab: string; content: Snippet; } @@ -27,6 +28,7 @@ viewerFollowRkey: viewerFollowRkeyProp, notJoined: notJoinedProp, bobbinUrl, + delegationDid, activeTab, content }: Props = $props(); @@ -46,6 +48,7 @@ {viewerFollowRkey} {notJoined} {bobbinUrl} + {delegationDid} {activeTab} {content} /> diff --git a/web/src/lib/components/profile/ProfileTabs.svelte b/web/src/lib/components/profile/ProfileTabs.svelte index b72456b8b..cf71d51c2 100644 --- a/web/src/lib/components/profile/ProfileTabs.svelte +++ b/web/src/lib/components/profile/ProfileTabs.svelte @@ -6,13 +6,8 @@ import Star from "$icon/star"; import LineSquiggle from "$icon/line-squiggle"; import Shield from "$icon/shield"; - import UsersRound from "$icon/users-round"; import Settings from "$icon/settings"; import Tabs, { type TabDef } from "$lib/components/ui/Tabs.svelte"; - import { - organizationMemberCount, - viewerIsOrganizationOwner - } from "$lib/components/profile/organization/mock"; import type { ProfileCounts } from "$lib/components/profile/types"; interface Props { @@ -20,9 +15,10 @@ active: string; counts: ProfileCounts; organization?: boolean; + settings?: boolean; } - let { handle, active, counts, organization = false }: Props = $props(); + let { handle, active, counts, organization = false, settings = false }: Props = $props(); const hrefFor = (id: string) => { const query = new URLSearchParams([ @@ -50,20 +46,13 @@ count: counts.repos, href: hrefFor("repos") }, - { - id: "members", - label: "Members", - icon: UsersRound, - count: organizationMemberCount, - href: hrefFor("members") - }, - ...(viewerIsOrganizationOwner + ...(settings ? [ { id: "settings", label: "Settings", icon: Settings, - href: `/${handle}/settings` + href: hrefFor("settings") } ] : []) diff --git a/web/src/lib/components/profile/organization/OrganizationChrome.svelte b/web/src/lib/components/profile/organization/OrganizationChrome.svelte index 9f0f16861..53c16b9af 100644 --- a/web/src/lib/components/profile/organization/OrganizationChrome.svelte +++ b/web/src/lib/components/profile/organization/OrganizationChrome.svelte @@ -4,7 +4,10 @@ import type { Snippet } from "svelte"; import UserRoundCog from "$icon/user-round-cog"; import Warehouse from "$icon/warehouse"; + import { createLoad } from "$lib/action.svelte"; + import { viewerControlsOrganization } from "$lib/api/organizations"; import type { ProfileRecord } from "$lib/api/records"; + import { getAuth } from "$lib/auth.svelte"; import FollowButton from "$lib/components/profile/FollowButton.svelte"; import { organizationManager } from "$lib/components/profile/organization/mock"; import ProfileMoreMenu from "$lib/components/profile/ProfileMoreMenu.svelte"; @@ -22,11 +25,27 @@ profile: ProfileRecord; counts: ProfileCounts; viewerFollowRkey?: string | null; + delegationDid?: string; activeTab: string; content: Snippet; } - let { identity, profile, counts, viewerFollowRkey, activeTab, content }: Props = $props(); + let { + identity, + profile, + counts, + viewerFollowRkey, + delegationDid = "", + activeTab, + content + }: Props = $props(); + + const auth = getAuth(); + const controls = createLoad(async () => { + const agent = auth.agent; + if (!agent || !delegationDid) return false; + return viewerControlsOrganization(agent, delegationDid, identity.did); + }); const blurLayers = [1.5, 2, 4, 6]; @@ -40,7 +59,13 @@ ); - + {#if activeTab === "overview"}
diff --git a/web/src/lib/components/profile/organization/OrganizationMembersTab.svelte b/web/src/lib/components/profile/organization/OrganizationMembersTab.svelte deleted file mode 100644 index 78d253852..000000000 --- a/web/src/lib/components/profile/organization/OrganizationMembersTab.svelte +++ /dev/null @@ -1,85 +0,0 @@ - - -
-

Members

- -
- - {#if viewerIsOrganizationOwner} - - {/if} -
- - {#if results.length === 0} - - {:else} -
- {#each results as person (person.did)} - - {/each} -
- {/if} -
- -{#if viewerIsOrganizationOwner} - - - -{/if} diff --git a/web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte b/web/src/lib/components/settings/tabs/OrganizationsTab.stories.svelte index 496dcb047..06bf0336b 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 @@ - {#if loaded.loading} @@ -126,56 +83,19 @@ icon={Building} title={organization.handle} href={`/${organization.handle}`} - stack + description={organization.role === "owner" ? "Owner" : "Member"} > - {#snippet tags()} - - {organization.role === "owner" ? "Owner" : "Member"} - - {/snippet} - {#snippet meta()} - - {organization.description - ? `${organization.description} ยท ${counts(organization)}` - : counts(organization)} - - {/snippet} {#if organization.role === "owner"} {/if} - - confirmLeave(organization)} - > - Leave - - {/each} {/if} - - diff --git a/web/src/routes/[handle]/+layout.svelte b/web/src/routes/[handle]/+layout.svelte index 37317005a..64e437459 100644 --- a/web/src/routes/[handle]/+layout.svelte +++ b/web/src/routes/[handle]/+layout.svelte @@ -12,21 +12,11 @@ let { children, data } = $props(); - const settingsRoute = $derived(page.route.id?.startsWith("/[handle]/settings")); - const tabParam = $derived( - settingsRoute ? "settings" : (page.url.searchParams.get("tab") ?? "overview") - ); + const tabParam = $derived(page.url.searchParams.get("tab") ?? "overview"); const activeTab = $derived( - [ - "repos", - "starred", - "strings", - "vouches", - "followers", - "following", - "members", - "settings" - ].includes(tabParam) + ["repos", "starred", "strings", "vouches", "followers", "following", "settings"].includes( + tabParam + ) ? tabParam : "overview" ); @@ -125,7 +115,7 @@ }); -{#if settingsRoute} +{#if activeTab === "settings"} {:else} diff --git a/web/src/routes/[handle]/+page.svelte b/web/src/routes/[handle]/+page.svelte index 37ae972aa..82e766aca 100644 --- a/web/src/routes/[handle]/+page.svelte +++ b/web/src/routes/[handle]/+page.svelte @@ -5,8 +5,8 @@ import StarredTab from "$lib/components/profile/tabs/StarredTab.svelte"; import StringListTab from "$lib/components/profile/tabs/StringListTab.svelte"; import VouchTab from "$lib/components/profile/tabs/VouchTab.svelte"; - import OrganizationMembersTab from "$lib/components/profile/organization/OrganizationMembersTab.svelte"; import OrganizationOverviewTab from "$lib/components/profile/organization/OrganizationOverviewTab.svelte"; + import OrganizationSettingsTab from "$routes/[handle]/settings/OrganizationSettingsTab.svelte"; import Pending from "$lib/components/ui/Pending.svelte"; import Bones from "$lib/components/ui/Bones.svelte"; import Section from "$lib/components/ui/Section.svelte"; @@ -16,6 +16,7 @@ import profileRepolisttabSkeletonFixture from "$lib/bones/profile-repolisttab--skeleton-fixture.bones.json"; import profileStringcardDefault from "$lib/bones/profile-stringcard--default.bones.json"; import repoRepocardWithStarButton from "$lib/bones/repo-repocard--with-star-button.bones.json"; + import settingsSettingspanelContentSkeletonFixture from "$lib/bones/settings-settingspanel--content-skeleton-fixture.bones.json"; let { data } = $props(); @@ -120,8 +121,15 @@ {@const identity = await data.identity} -{:else if data.tab === "members"} - {#if (await data.profile)?.isOrganization} - - {/if} +{:else if data.tab === "settings"} + + {#snippet skeleton()} + + {/snippet} + + {/if} diff --git a/web/src/routes/[handle]/+page.ts b/web/src/routes/[handle]/+page.ts index f9bbbf786..e61df9af2 100644 --- a/web/src/routes/[handle]/+page.ts +++ b/web/src/routes/[handle]/+page.ts @@ -20,7 +20,6 @@ const TABS = [ "followers", "following", "vouches", - "members", "settings" ] as const; type Tab = (typeof TABS)[number]; @@ -82,8 +81,6 @@ export const load: PageLoad = async (event) => { isSelf: stream(identity.then(({ did }) => viewerDid === did)) }); } - case "members": - return { tab: "members" as const }; case "settings": return { tab: "settings" as const }; case "starred": { diff --git a/web/src/routes/[handle]/handle.test.ts b/web/src/routes/[handle]/handle.test.ts index 67bd3ab92..da6e652b8 100644 --- a/web/src/routes/[handle]/handle.test.ts +++ b/web/src/routes/[handle]/handle.test.ts @@ -51,7 +51,7 @@ const makeMockFetch = () => }); describe("/[handle] canonical navigation", () => { - it.each(["", "/settings", "/settings/members", "/settings/members/new"])( + it.each(["", "/some-repo", "/some-repo/issues"])( "preserves the %s suffix and search parameters", async (suffix) => { const event = { diff --git a/web/src/routes/[handle]/settings/+layout.svelte b/web/src/routes/[handle]/settings/+layout.svelte deleted file mode 100644 index 75c7f1ffc..000000000 --- a/web/src/routes/[handle]/settings/+layout.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - -{#if viewerIsOrganizationOwner} - - {@render children()} - -{:else} - -{/if} diff --git a/web/src/routes/[handle]/settings/+page.svelte b/web/src/routes/[handle]/settings/+page.svelte deleted file mode 100644 index f10420d35..000000000 --- a/web/src/routes/[handle]/settings/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/web/src/routes/[handle]/settings/avatar/+page.svelte b/web/src/routes/[handle]/settings/OrganizationAvatar.svelte similarity index 58% rename from web/src/routes/[handle]/settings/avatar/+page.svelte rename to web/src/routes/[handle]/settings/OrganizationAvatar.svelte index cd817caa3..8217a49d9 100644 --- a/web/src/routes/[handle]/settings/avatar/+page.svelte +++ b/web/src/routes/[handle]/settings/OrganizationAvatar.svelte @@ -1,11 +1,14 @@ diff --git a/web/src/routes/[handle]/settings/OrganizationProfileTab.stories.svelte b/web/src/routes/[handle]/settings/OrganizationProfileTab.stories.svelte index 3822ff632..264b2ec0e 100644 --- a/web/src/routes/[handle]/settings/OrganizationProfileTab.stories.svelte +++ b/web/src/routes/[handle]/settings/OrganizationProfileTab.stories.svelte @@ -16,7 +16,7 @@ await expect(canvas.getByRole("heading", { name: "Profile" })).toBeVisible(); await expect(canvas.getByRole("link", { name: "Change avatar" })).toHaveAttribute( "href", - expect.stringContaining("/settings/avatar") + expect.stringContaining("?tab=settings§ion=avatar") ); await expect(canvas.getByRole("textbox", { name: "Description" })).toBeVisible(); await expect(canvas.getByRole("button", { name: "Delete organization" })).toBeVisible(); diff --git a/web/src/routes/[handle]/settings/OrganizationProfileTab.svelte b/web/src/routes/[handle]/settings/OrganizationProfileTab.svelte index 29c61ca36..825a06c15 100644 --- a/web/src/routes/[handle]/settings/OrganizationProfileTab.svelte +++ b/web/src/routes/[handle]/settings/OrganizationProfileTab.svelte @@ -21,7 +21,6 @@ const auth = getAuth(); const handle = $derived(decodeURIComponent(page.params.handle ?? "")); - const base = $derived(`/${handle}/settings`); const identity = createLoad(async () => { const ctx = createBobbinClient({ serviceUrl: auth.bobbinUrl }); @@ -56,7 +55,9 @@
- +
+ import { page } from "$app/state"; + import { createLoad } from "$lib/action.svelte"; + import { viewerControlsOrganization } from "$lib/api/organizations"; + import type { ProfileRecord } from "$lib/api/records"; + import { getAuth } from "$lib/auth.svelte"; + import settingsSettingspanelContentSkeletonFixture from "$lib/bones/settings-settingspanel--content-skeleton-fixture.bones.json"; + import type { SettingsNavItem } from "$lib/components/settings/SettingsNav.svelte"; + import SettingsPanel from "$lib/components/settings/SettingsPanel.svelte"; + import KnotsTab from "$lib/components/settings/tabs/KnotsTab.svelte"; + import SitesTab from "$lib/components/settings/tabs/SitesTab.svelte"; + import SpindlesTab from "$lib/components/settings/tabs/SpindlesTab.svelte"; + import Bones from "$lib/components/ui/Bones.svelte"; + import Error from "$lib/components/ui/Error.svelte"; + 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 OrganizationProfileTab from "$routes/[handle]/settings/OrganizationProfileTab.svelte"; + import User from "$icon/user-round"; + import Volleyball from "$icon/volleyball"; + import Spool from "$icon/spool"; + import Globe from "$icon/globe"; + import Users from "$icon/users"; + + interface Props { + identity: { did: string; handle: string }; + profile: ProfileRecord | null; + delegationDid?: string; + } + + let { identity, profile, delegationDid = "" }: Props = $props(); + + const auth = getAuth(); + const controls = createLoad(async () => { + const agent = auth.agent; + if (!agent || !delegationDid || !profile?.isOrganization) return false; + return viewerControlsOrganization(agent, delegationDid, identity.did); + }); + + const base = $derived(`/${identity.handle}?tab=settings`); + const items: SettingsNavItem[] = $derived([ + { id: "profile", label: "Profile", href: base, icon: User }, + { id: "knots", label: "Knots", href: `${base}§ion=knots`, icon: Volleyball }, + { id: "spindles", label: "Spindles", href: `${base}§ion=spindles`, icon: Spool }, + { id: "sites", label: "Sites", href: `${base}§ion=sites`, icon: Globe }, + { id: "members", label: "Members", href: `${base}§ion=members`, icon: Users } + ]); + + const section = $derived(page.url.searchParams.get("section") ?? "profile"); + const active = $derived( + section === "avatar" ? "profile" : section === "invite" ? "members" : section + ); + + +{#if controls.loading} + +{:else if controls.error} +
+ +
+{:else if controls.data} + + {#if section === "avatar"} + + {:else if section === "knots"} + + {:else if section === "spindles"} + + {:else if section === "sites"} + + {:else if section === "members"} + + {:else if section === "invite"} + + {:else} + + {/if} + +{:else} +
+ +
+{/if} diff --git a/web/src/routes/[handle]/settings/knots/+page.svelte b/web/src/routes/[handle]/settings/knots/+page.svelte deleted file mode 100644 index e1d99a5a3..000000000 --- a/web/src/routes/[handle]/settings/knots/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/web/src/routes/[handle]/settings/members/+page.svelte b/web/src/routes/[handle]/settings/members/+page.svelte deleted file mode 100644 index 6e9c178ea..000000000 --- a/web/src/routes/[handle]/settings/members/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/web/src/routes/[handle]/settings/members/new/+page.svelte b/web/src/routes/[handle]/settings/members/InviteMembers.svelte similarity index 79% rename from web/src/routes/[handle]/settings/members/new/+page.svelte rename to web/src/routes/[handle]/settings/members/InviteMembers.svelte index 21dc4c3a9..809cfa6a8 100644 --- a/web/src/routes/[handle]/settings/members/new/+page.svelte +++ b/web/src/routes/[handle]/settings/members/InviteMembers.svelte @@ -1,13 +1,17 @@ diff --git a/web/src/routes/[handle]/settings/members/MembersTab.stories.svelte b/web/src/routes/[handle]/settings/members/MembersTab.stories.svelte index 9197d9afc..49e7f5b98 100644 --- a/web/src/routes/[handle]/settings/members/MembersTab.stories.svelte +++ b/web/src/routes/[handle]/settings/members/MembersTab.stories.svelte @@ -7,7 +7,7 @@ const { Story } = defineMeta({ title: "Settings/MembersTab", component: MembersTab, - parameters: { sveltekit: { state: { page: { params: { handle: "tangled.org" } } } } } + args: { handle: "tangled.org" } }); @@ -20,7 +20,7 @@ ); await expect(canvas.getByRole("link", { name: "Add member" })).toHaveAttribute( "href", - "/tangled.org/settings/members/new" + "/tangled.org?tab=settings§ion=invite" ); await userEvent.type(search, " UsEr.TLD "); await expect(canvas.getByRole("link", { name: "user.tld" })).toBeVisible(); diff --git a/web/src/routes/[handle]/settings/members/MembersTab.svelte b/web/src/routes/[handle]/settings/members/MembersTab.svelte index 5614fe2db..99bb05a3b 100644 --- a/web/src/routes/[handle]/settings/members/MembersTab.svelte +++ b/web/src/routes/[handle]/settings/members/MembersTab.svelte @@ -1,5 +1,4 @@ - - diff --git a/web/src/routes/[handle]/settings/spindles/+page.svelte b/web/src/routes/[handle]/settings/spindles/+page.svelte deleted file mode 100644 index e0beff6a4..000000000 --- a/web/src/routes/[handle]/settings/spindles/+page.svelte +++ /dev/null @@ -1,5 +0,0 @@ - - -