diff --git a/src/app/__tests__/public-endorsements-groups.test.tsx b/src/app/__tests__/public-endorsements-groups.test.tsx index 247a8377..33e02d98 100644 --- a/src/app/__tests__/public-endorsements-groups.test.tsx +++ b/src/app/__tests__/public-endorsements-groups.test.tsx @@ -2,20 +2,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen, fireEvent, cleanup } from "@testing-library/react" /** - * P1: /endorsements and /groups used to redirect anonymous (signed-out) - * visitors to /welcome via the route-level . That guard was - * removed; instead each page renders a public sign-in prompt in place. + * P1: /endorsements used to redirect anonymous (signed-out) visitors to + * /welcome via the route-level . That guard was removed; + * instead the page renders a public sign-in prompt in place. * - * Both pages are personal, owner-scoped surfaces (a personal inbox and - * the viewer's own membership list, keyed on the viewer's DID), so there - * is no public listing to show — the "best available public view" is an - * explanation + a sign-in CTA. These tests pin that: + * /endorsements is a personal, owner-scoped surface (a personal inbox + * keyed on the viewer's DID), so there is no public listing to show — + * the "best available public view" is an explanation + a sign-in CTA. + * These tests pin that: * - signed-out renders the prompt (no crash, no error state), * - the CTA invokes the shared openSignIn() flow, * - signed-in still renders the real management UI. * - * The shared SignedOutPrompt lets us assert one openSignIn() spy across - * both routes. + * (The sibling /groups index has since been retired — it now redirects + * to /home — so only /endorsements remains covered here.) */ const openSignIn = vi.fn().mockResolvedValue(undefined) @@ -41,7 +41,7 @@ vi.mock("next/navigation", () => ({ useSearchParams: () => new URLSearchParams(""), })) -// --- /groups dependencies ------------------------------------------------- +// org-context is pulled in transitively by the endorsements page chrome. vi.mock("@/lib/groups/org-context", () => ({ useOrg: () => ({ activeOrg: null, @@ -52,12 +52,6 @@ vi.mock("@/lib/groups/org-context", () => ({ }), })) -vi.mock("@/lib/groups/api", () => ({ - putMembership: vi.fn().mockResolvedValue(undefined), - deleteMembership: vi.fn().mockResolvedValue(undefined), - removeOrgMember: vi.fn().mockResolvedValue(undefined), -})) - // --- /endorsements dependencies ------------------------------------------ vi.mock("@/hooks/use-endorsements", () => ({ useGivenEndorsements: () => ({ @@ -119,30 +113,3 @@ describe("/endorsements public view (signed out)", () => { expect(screen.getByRole("tab", { name: "Given" })).toBeTruthy() }) }) - -describe("/groups public view (signed out)", () => { - it("renders the sign-in prompt instead of redirecting", async () => { - const { default: GroupsPage } = await import("../../app/groups/page") - render() - - expect(screen.getByText("Sign in to see your groups")).toBeTruthy() - expect(replace).not.toHaveBeenCalled() - }) - - it("invokes openSignIn when the CTA is clicked", async () => { - const { default: GroupsPage } = await import("../../app/groups/page") - render() - - fireEvent.click(screen.getByRole("button", { name: "Sign in" })) - expect(openSignIn).toHaveBeenCalledTimes(1) - }) - - it("renders the real Groups UI when authenticated", async () => { - authState = { did: "did:plc:me", isAuthenticated: true, isLoading: false } - const { default: GroupsPage } = await import("../../app/groups/page") - render() - - expect(screen.queryByText("Sign in to see your groups")).toBeNull() - expect(screen.getByRole("heading", { name: "Membership" })).toBeTruthy() - }) -}) diff --git a/src/app/groups/__tests__/leave-group-confirm-dialog.test.tsx b/src/app/groups/__tests__/leave-group-confirm-dialog.test.tsx deleted file mode 100644 index db14f1dd..00000000 --- a/src/app/groups/__tests__/leave-group-confirm-dialog.test.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest" -import { render, screen, fireEvent, cleanup, within, waitFor } from "@testing-library/react" - -/** - * quality-056 / groups-3: the Leave-Group confirmation on /groups was a - * hand-rolled `signin-modal__backdrop` div (no focus trap / Esc / - * scroll-lock) instead of the shared `` used everywhere - * else (e.g. org-settings). Per CLAUDE.md hard rule 7, modals must go - * through ``/``. This test opens the Leave - * flow and pins that the confirmation renders as a native `` - * (the AppDialog chrome ConfirmDialog wraps) — NOT the bespoke backdrop - * div — while preserving the confirm action + copy. - * - * GroupsPage pulls in org-context, auth, navbar, and navigation; none - * are under test, so they're stubbed to inert defaults with a single - * member-role group so the "Leave" button renders. - */ - -const refetchOrgs = vi.fn().mockResolvedValue(undefined) - -vi.mock("@/lib/groups/org-context", () => ({ - useOrg: () => ({ - activeOrg: null, - groups: [ - { - groupDid: "did:plc:group1", - handle: "acme.example.com", - displayName: "Acme Co", - role: "member", - accepted: true, - avatarUrl: undefined, - }, - ], - isLoading: false, - switchOrg: vi.fn(), - refetchOrgs, - }), -})) - -vi.mock("@/lib/auth/auth-context", () => ({ - useAuth: () => ({ did: "did:plc:me", isAuthenticated: true }), -})) - -vi.mock("@/lib/navbar-context", () => ({ - usePageTitle: () => undefined, -})) - -vi.mock("next/navigation", () => ({ - usePathname: () => "/groups", - useRouter: () => ({ replace: vi.fn(), push: vi.fn() }), - useSearchParams: () => new URLSearchParams(""), -})) - -vi.mock("@/lib/groups/api", () => ({ - putMembership: vi.fn().mockResolvedValue(undefined), - deleteMembership: vi.fn().mockResolvedValue(undefined), - removeOrgMember: vi.fn().mockResolvedValue(undefined), -})) - -/** - * jsdom's `HTMLDialogElement` doesn't implement `showModal`/`close`. - * Polyfill enough for AppDialog's mount effect to drive the real path. - */ -function polyfillDialog() { - const proto = HTMLDialogElement.prototype as unknown as { - showModal?: () => void - close?: () => void - __polyfilled?: boolean - } - if (proto.__polyfilled) return - proto.__polyfilled = true - proto.showModal = function () { - ;(this as unknown as { open: boolean }).open = true - } - proto.close = function () { - ;(this as unknown as { open: boolean }).open = false - ;(this as unknown as HTMLDialogElement).dispatchEvent(new Event("close")) - } -} - -beforeEach(() => { - polyfillDialog() - cleanup() - refetchOrgs.mockClear() -}) - -describe("Leave Group confirmation uses the shared ConfirmDialog", () => { - it("renders the confirmation as a native , not a hand-rolled backdrop", async () => { - const { default: GroupsPage } = await import("../page") - const { container } = render() - - fireEvent.click(screen.getByRole("button", { name: "Leave" })) - - // ConfirmDialog -> AppDialog renders a real native with - // the shared (now self-contained Tailwind) modal chrome. - const dialog = screen.getByRole("alertdialog", { hidden: true }) - expect(dialog.tagName).toBe("DIALOG") - expect(dialog.getAttribute("aria-label")).toBe("Leave Group") - - // The bespoke hand-rolled backdrop must be gone. - expect(container.querySelector(".signin-modal__backdrop")).toBeNull() - }) - - it("preserves the confirm copy and the leave action inside the dialog", async () => { - const { default: GroupsPage } = await import("../page") - render() - - fireEvent.click(screen.getByRole("button", { name: "Leave" })) - - // Copy is preserved (group name + warning). - const dialog = screen.getByRole("alertdialog", { hidden: true }) - expect(dialog.textContent).toContain("Acme Co") - expect(dialog.textContent).toContain("lose access to this group") - - // The confirm action still lives in the dialog: clicking the - // footer confirm button runs the leave flow (refetchOrgs is the - // observable tail of handleLeaveOrg). - const confirm = within(dialog).getByRole("button", { name: "Leave" }) - fireEvent.click(confirm) - await waitFor(() => expect(refetchOrgs).toHaveBeenCalled()) - }) -}) diff --git a/src/app/groups/create/page.tsx b/src/app/groups/create/page.tsx index 2910e810..a9e7b0b7 100644 --- a/src/app/groups/create/page.tsx +++ b/src/app/groups/create/page.tsx @@ -274,7 +274,7 @@ export default function CreateGroupPage() { // in the account switcher + group list. await putMembership(did, groupDid, "owner") await refetchOrgs() - router.push("/groups") + router.push("/home") } catch (err) { console.error("[groups/create] failed", err) if ( @@ -343,9 +343,9 @@ export default function CreateGroupPage() { > @@ -592,7 +592,7 @@ export default function CreateGroupPage() { - ) : ( - - )} - {canLeaveMap[org.groupDid] && ( - - )} - - - ) - - const handleRemovePublicMembership = async (groupDid: string) => { - if (!did) return - setRemovingPublic(groupDid) - try { - await deleteMembership(did, groupDid) - await refetchOrgs() - } catch (err) { - console.error("Failed to remove public membership:", err) - } finally { - setRemovingPublic(null) - } - } - - const handleLeaveOrg = async () => { - if (!did || !leaveOrg) return - setIsLeaving(true) - try { - // Remove from group service (actual access removal) - await removeOrgMember(leaveOrg.groupDid, did) - // Also clean up local PDS record if it exists - await deleteMembership(did, leaveOrg.groupDid).catch(() => {}) - await refetchOrgs() - setLeaveOrg(null) - } catch (err) { - console.error("Failed to leave group:", err) - } finally { - setIsLeaving(false) - } - } - - const handleAcceptMembership = async (groupDid: string, role: OrgRole) => { - if (!did) return - setAcceptingOrg(groupDid) - try { - await putMembership(did, groupDid, role) - await refetchOrgs() - } catch (err) { - console.error("Failed to accept membership:", err) - } finally { - setAcceptingOrg(null) - } - } - - const renderPanelBody = (tab: TabKey) => { - const visibleOrgs = tab === "public" ? acceptedOrgs : pendingOrgs - if (isLoading) { - return ( -
- -
- ) - } - if (visibleOrgs.length === 0) { - return ( -

- {tab === "public" - ? "No public memberships yet. Make a private membership public from this list to share it on your profile." - : "No private memberships. Pending invites and memberships you've removed from public view appear here."} -

- ) - } - return
{visibleOrgs.map(renderOrgItem)}
- } - - // Anonymous visitors used to be redirected to /welcome by AuthGuard. - // /groups lists the viewer's OWN memberships (keyed on their DID) — - // there's no public listing to render — so we show a public sign-in - // prompt in place instead of bouncing. Wait for auth to resolve first - // so signed-in users never flash the prompt. - if (authLoading) { - return ( -
-
-
-
- -
-
-
-
- ) - } - if (!isAuthenticated) { - return ( -
-
-
- -
-
-
- ) - } - - return ( -
-
-
-

Membership

- - changeTab(v as TabKey)}> -
- {/* The surrounding .page-tabs-bar (feed.css, cross-track) already - draws the strip's bottom border, so drop TabList's own. */} - - {TABS.map((tab) => ( - - {tab.label} - - ))} - - - -
- - {TABS.map((tab) => ( - - {renderPanelBody(tab.key)} - - ))} -
-
-
- - {leaveOrg && ( - setLeaveOrg(null)} - onConfirm={handleLeaveOrg} - /> - )} -
- ) +import { redirect } from "next/navigation" + +/** + * The standalone `/groups` listing has been replaced by the profile + * Groups tab (e.g. `/{handle}?tab=groups`). The page no longer exists as + * its own surface; retire the URL with a redirect to `/home` so any old + * link or bookmark lands somewhere sensible instead of 404ing. + * + * The sub-routes — `/groups/create`, `/groups/import`, and + * `/groups/[groupDid]` — are unaffected (they keep the shared layout). + */ +export default function GroupsIndexPage() { + redirect("/home") } diff --git a/src/components/groups/org-settings.tsx b/src/components/groups/org-settings.tsx index 15dcfbb0..8d995c3f 100644 --- a/src/components/groups/org-settings.tsx +++ b/src/components/groups/org-settings.tsx @@ -129,7 +129,7 @@ export default function OrgSettings({ groupDid, org }: OrgSettingsProps) { await destroyGroup(groupDid) setConfirmDestroy(false) // The group is gone from the service — leave the settings page. - router.push("/") + router.push("/home") } catch (err) { setDestroyError( err instanceof Error ? err.message : "Failed to remove group", diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index 0b7338c7..655c24e8 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -41,7 +41,6 @@ const ROOT_PATHS = new Set([ "/apps", "/profile", "/settings", - "/groups", "/endorsements", "/help", ]);