Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 4 additions & 19 deletions app/events/page.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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)
Expand Down
6 changes: 2 additions & 4 deletions app/joinrequeststatus/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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(
Expand Down
11 changes: 11 additions & 0 deletions app/organization-not-found/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export default function OrganizationNotFoundPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-4 bg-white px-6 text-center">
<h1 className="text-grey-text-strong">Organization not found</h1>
<p className="text-desktop-paragraph-1 text-grey-text-weak">
Check the spelling of your organization&rsquo;s address, or ask whoever
sent you the link.
</p>
</main>
);
}
13 changes: 0 additions & 13 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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)
Expand Down
15 changes: 9 additions & 6 deletions lib/authUtils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions lib/navbar.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -47,6 +48,7 @@ export const NAVBAR_ITEMS: NavbarItem[] = [
];

export const NO_NAVBAR_PAGES = [
ORGANIZATION_NOT_FOUND_PATH,
"/login",
"/signup",
"/joinrequeststatus",
Expand Down
1 change: 1 addition & 0 deletions lib/organizationRoutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const ORGANIZATION_NOT_FOUND_PATH = "/organization-not-found";
72 changes: 72 additions & 0 deletions tests/unit/lib/authUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
);
},
);
});
Loading