From ddff62dad0d8a88d6b8195a10a2a6a6abfc4e2b3 Mon Sep 17 00:00:00 2001 From: David Gu Date: Mon, 14 Sep 2026 00:06:19 -0400 Subject: [PATCH 1/2] Fix authenticated redirects when no organization resolves --- app/events/page.tsx | 23 ++------- app/joinrequeststatus/page.tsx | 6 +-- app/organization-not-found/page.tsx | 6 +++ app/page.tsx | 13 ------ components/OrganizationNotFound.tsx | 38 +++++++++++++++ lib/authUtils.ts | 15 +++--- lib/navbar.ts | 2 + lib/organizationRoutes.ts | 1 + tests/unit/lib/authUtils.test.ts | 72 +++++++++++++++++++++++++++++ 9 files changed, 134 insertions(+), 42 deletions(-) create mode 100644 app/organization-not-found/page.tsx create mode 100644 components/OrganizationNotFound.tsx create mode 100644 lib/organizationRoutes.ts diff --git a/app/events/page.tsx b/app/events/page.tsx index f053d6c6..5a22f2b3 100644 --- a/app/events/page.tsx +++ b/app/events/page.tsx @@ -1,7 +1,5 @@ -import { headers } from "next/headers"; -import { redirect } from "next/navigation"; import EventsPageClient from "@/components/events/EventsPageClient"; -import { auth } from "@/lib/auth"; +import { redirectIfNotMember } from "@/lib/authUtils"; import EventService from "@/lib/services/EventService"; import { MembersService } from "@/lib/services/MemberService"; @@ -15,28 +13,15 @@ interface EventsPageProps { } export default async function EventsPage({ searchParams }: EventsPageProps) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.user) { - redirect("/login"); - } - - const organizationId = session.session.activeOrganizationId; - if (!organizationId) { - redirect("/"); - } + const session = await redirectIfNotMember(); + const { organizationId } = session; const membership = await MembersService.findByUserAndOrganization( session.user.id, organizationId, ); - if (!membership) { - redirect("/"); - } - const isAdmin = MembersService.isAdminOrOwner(membership.role); + const isAdmin = MembersService.isAdminOrOwner(membership?.role); const search = await searchParams; const query = Array.isArray(search?.query) ? search.query[0] : search?.query; const filterParam = Array.isArray(search?.filter) diff --git a/app/joinrequeststatus/page.tsx b/app/joinrequeststatus/page.tsx index 7eeffad5..707624eb 100644 --- a/app/joinrequeststatus/page.tsx +++ b/app/joinrequeststatus/page.tsx @@ -15,7 +15,7 @@ import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { createJoinRequestIfNeeded, - getActiveOrganizationIdFromHeaders, + requireOrganizationOrRedirect, } from "@/lib/authUtils"; import { JoinRequestStatus } from "@/lib/schema"; import { JoinRequestsService } from "@/lib/services/JoinRequestService"; @@ -194,9 +194,7 @@ export default async function JoinRequestStatusPage() { if (!session?.user) redirect("/login"); const userId = session.user.id; - const organizationId = - await getActiveOrganizationIdFromHeaders(requestHeaders); - if (!organizationId) redirect("/"); + const organizationId = await requireOrganizationOrRedirect(requestHeaders); const activeOrgId = organizationId; const membership = await MembersService.findByUserAndOrganization( diff --git a/app/organization-not-found/page.tsx b/app/organization-not-found/page.tsx new file mode 100644 index 00000000..81e5decf --- /dev/null +++ b/app/organization-not-found/page.tsx @@ -0,0 +1,6 @@ +import OrganizationNotFound from "@/components/OrganizationNotFound"; + +// This terminal destination must never invoke a session or membership guard. +export default function OrganizationNotFoundPage() { + return ; +} diff --git a/app/page.tsx b/app/page.tsx index e2297d45..980c574a 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,9 +1,6 @@ -import { redirect } from "next/navigation"; import { redirectIfNotMember } from "@/lib/authUtils"; import { MembersService } from "@/lib/services/MemberService"; -import { JoinRequestsService } from "@/lib/services/JoinRequestService"; import { OrganizationConfigService } from "@/lib/services/OrganizationConfigService"; -import { JoinRequestStatus } from "@/lib/schema"; import DashboardGrid from "@/components/dashboard/DashboardGrid"; export const metadata = { @@ -18,16 +15,6 @@ export default async function Page() { session.user.id, organizationId, ); - if (!membership) { - const joinRequest = await JoinRequestsService.findByUserAndOrganization( - session.user.id, - organizationId, - ); - if (joinRequest?.status === JoinRequestStatus.Pending) { - redirect("/joinrequeststatus"); - } - } - const isAdmin = MembersService.isAdminOrOwner(membership?.role) ?? false; const layout = isAdmin ? await OrganizationConfigService.getAdminDashboardLayout(organizationId) diff --git a/components/OrganizationNotFound.tsx b/components/OrganizationNotFound.tsx new file mode 100644 index 00000000..0956cfd5 --- /dev/null +++ b/components/OrganizationNotFound.tsx @@ -0,0 +1,38 @@ +"use client"; + +/** + * Shown on the unauthenticated pages when the requested subdomain has no + * matching organization (issue #252). + * + * Deliberately unbranded: a missing organization has no branding, and rendering + * placeholder colors is the failure this fixes. No sign-in form either — if + * there is a box, people type in it, and they are back at the dead end. + */ +export default function OrganizationNotFound() { + const host = typeof window === "undefined" ? "" : window.location.host; + // `acme.servicestart.com` -> `servicestart.com`. Avoids a config value; + // NEXT_PUBLIC_BASE_URL points at a single tenant and is unusable here. + const parentDomain = host.split(".").slice(1).join("."); + + return ( +
+

+ There’s no nonprofit at {host}. +

+

+ Check the spelling, or ask whoever sent you the link. +

+ {parentDomain && ( + + Go to ServiceStart → + + )} +
+ ); +} diff --git a/lib/authUtils.ts b/lib/authUtils.ts index 0b817afb..db1a2dc8 100644 --- a/lib/authUtils.ts +++ b/lib/authUtils.ts @@ -1,3 +1,4 @@ +import { ORGANIZATION_NOT_FOUND_PATH } from "@/lib/organizationRoutes"; import type { Context } from "hono"; import { headers } from "next/headers"; import { redirect } from "next/navigation"; @@ -217,6 +218,13 @@ export async function getActiveOrganizationIdFromHeaders( return organization.id; } +/** Resolves a real organization or stops at the unguarded missing-org page. */ +export async function requireOrganizationOrRedirect(headerList: Headers) { + const organizationId = await getActiveOrganizationIdFromHeaders(headerList); + if (!organizationId) redirect(ORGANIZATION_NOT_FOUND_PATH); + return organizationId; +} + /** * Loads the Better Auth session for a Next.js server request. Redirects to * `/login` when there is no signed-in user. @@ -241,12 +249,7 @@ export async function redirectIfNotMember() { const requestHeaders = await headers(); const session = await requireSessionOrRedirect(requestHeaders); - const organizationId = - await getActiveOrganizationIdFromHeaders(requestHeaders); - - if (!organizationId) { - redirect("/"); - } + const organizationId = await requireOrganizationOrRedirect(requestHeaders); const membership = await MembersService.findByUserAndOrganization( session.user.id, diff --git a/lib/navbar.ts b/lib/navbar.ts index 94bd0fba..10a7a0f8 100644 --- a/lib/navbar.ts +++ b/lib/navbar.ts @@ -1,3 +1,4 @@ +import { ORGANIZATION_NOT_FOUND_PATH } from "@/lib/organizationRoutes"; import type { IconName } from "@/components/bog/BogIcon/BogIcon"; import { OrganizationConfigKey, ToggleableOrganizationFeature } from "./schema"; @@ -47,6 +48,7 @@ export const NAVBAR_ITEMS: NavbarItem[] = [ ]; export const NO_NAVBAR_PAGES = [ + ORGANIZATION_NOT_FOUND_PATH, "/login", "/signup", "/joinrequeststatus", diff --git a/lib/organizationRoutes.ts b/lib/organizationRoutes.ts new file mode 100644 index 00000000..81239163 --- /dev/null +++ b/lib/organizationRoutes.ts @@ -0,0 +1 @@ +export const ORGANIZATION_NOT_FOUND_PATH = "/organization-not-found"; diff --git a/tests/unit/lib/authUtils.test.ts b/tests/unit/lib/authUtils.test.ts index 4fdd18d9..f573bd3e 100644 --- a/tests/unit/lib/authUtils.test.ts +++ b/tests/unit/lib/authUtils.test.ts @@ -1,3 +1,9 @@ +// @vitest-environment node +import OrganizationNotFoundPage from "@/app/organization-not-found/page"; +import { renderToStaticMarkup } from "react-dom/server"; +import DashboardPage from "@/app/page"; +import EventsPage from "@/app/events/page"; +import JoinRequestStatusPage from "@/app/joinrequeststatus/page"; import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -343,3 +349,69 @@ describe("auth guard helpers", () => { expect(mockRedirect).not.toHaveBeenCalled(); }); }); + +describe("pages with no resolvable organization", () => { + it.each([ + ["/", () => DashboardPage()], + ["/events", () => EventsPage({ searchParams: Promise.resolve({}) })], + ["/joinrequeststatus", () => JoinRequestStatusPage()], + ] as const)( + "%s reaches a terminal destination", + async (_path, renderPage) => { + const { headers } = await signUpAndGetSession(buildTestUser()); + mockHeaders.mockReturnValue( + new Headers({ cookie: headers.Cookie, host: buildHost("missing") }), + ); + await expect(renderPage()).rejects.toThrow( + "NEXT_REDIRECT:/organization-not-found", + ); + }, + ); +}); + +describe("organization resolution regressions", () => { + it("renders the terminal page without a session or redirect", () => { + mockHeaders.mockReset(); + mockRedirect.mockClear(); + const markup = renderToStaticMarkup(OrganizationNotFoundPage()); + expect(markup).toContain("Check the spelling"); + expect(mockHeaders).not.toHaveBeenCalled(); + expect(mockRedirect).not.toHaveBeenCalled(); + }); + + it.each([ + ["/", () => DashboardPage()], + ["/events", () => EventsPage({ searchParams: Promise.resolve({}) })], + ] as const)( + "%s still admits members through the host fallback", + async (_path, renderPage) => { + const org = await createOrganization("member-org"); + const { user, headers } = await signUpAndGetSession(buildTestUser()); + await addMember(user.id, org.id, "member"); + mockHeaders.mockReturnValue( + new Headers({ cookie: headers.Cookie, host: buildHost("member-org") }), + ); + await expect(renderPage()).resolves.toBeDefined(); + }, + ); + + it.each([ + ["/", () => DashboardPage()], + ["/events", () => EventsPage({ searchParams: Promise.resolve({}) })], + ] as const)( + "%s still sends nonmembers to join request status", + async (_path, renderPage) => { + await createOrganization("nonmember-org"); + const { headers } = await signUpAndGetSession(buildTestUser()); + mockHeaders.mockReturnValue( + new Headers({ + cookie: headers.Cookie, + host: buildHost("nonmember-org"), + }), + ); + await expect(renderPage()).rejects.toThrow( + "NEXT_REDIRECT:/joinrequeststatus", + ); + }, + ); +}); From a48c9414db1a164a8419ea69aeb12f3ea41f074d Mon Sep 17 00:00:00 2001 From: David Gu Date: Mon, 14 Sep 2026 09:24:47 -0400 Subject: [PATCH 2/2] Render missing organization destination without client hydration --- app/organization-not-found/page.tsx | 13 +++++++--- components/OrganizationNotFound.tsx | 38 ----------------------------- 2 files changed, 9 insertions(+), 42 deletions(-) delete mode 100644 components/OrganizationNotFound.tsx diff --git a/app/organization-not-found/page.tsx b/app/organization-not-found/page.tsx index 81e5decf..41055ad0 100644 --- a/app/organization-not-found/page.tsx +++ b/app/organization-not-found/page.tsx @@ -1,6 +1,11 @@ -import OrganizationNotFound from "@/components/OrganizationNotFound"; - -// This terminal destination must never invoke a session or membership guard. export default function OrganizationNotFoundPage() { - return ; + return ( +
+

Organization not found

+

+ Check the spelling of your organization’s address, or ask whoever + sent you the link. +

+
+ ); } diff --git a/components/OrganizationNotFound.tsx b/components/OrganizationNotFound.tsx deleted file mode 100644 index 0956cfd5..00000000 --- a/components/OrganizationNotFound.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -/** - * Shown on the unauthenticated pages when the requested subdomain has no - * matching organization (issue #252). - * - * Deliberately unbranded: a missing organization has no branding, and rendering - * placeholder colors is the failure this fixes. No sign-in form either — if - * there is a box, people type in it, and they are back at the dead end. - */ -export default function OrganizationNotFound() { - const host = typeof window === "undefined" ? "" : window.location.host; - // `acme.servicestart.com` -> `servicestart.com`. Avoids a config value; - // NEXT_PUBLIC_BASE_URL points at a single tenant and is unusable here. - const parentDomain = host.split(".").slice(1).join("."); - - return ( -
-

- There’s no nonprofit at {host}. -

-

- Check the spelling, or ask whoever sent you the link. -

- {parentDomain && ( - - Go to ServiceStart → - - )} -
- ); -}