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
24 changes: 7 additions & 17 deletions app/members/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import {
getActiveOrganizationIdFromHeaders,
redirectIfNotAdmin,
} from "@/lib/authUtils";
import { MembersService } from "@/lib/services/MemberService";
import { OrganizationConfigService } from "@/lib/services/OrganizationConfigService";
import { OrganizationConfigKey } from "@/lib/schema";
Expand All @@ -13,28 +17,14 @@ interface MembersPageProps {
}

export default async function MembersPage({ searchParams }: MembersPageProps) {
const session = await auth.api.getSession({
headers: await headers(),
});
const session = await redirectIfNotAdmin();
const headerList = await headers();
const organizationId = await getActiveOrganizationIdFromHeaders(headerList);

if (!session?.user) {
redirect("/login");
}

const organizationId = session.session.activeOrganizationId;
if (!organizationId) {
redirect("/");
}

const membership = await MembersService.findByUserAndOrganization(
session.user.id,
organizationId,
);

if (!MembersService.isAdminOrOwner(membership?.role)) {
redirect("/");
}

const config = await OrganizationConfigService.getConfig(organizationId, [
OrganizationConfigKey.MembersPageEnabled,
]);
Expand Down
11 changes: 6 additions & 5 deletions tests/e2e/home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,21 @@ import { test, expect } from "@playwright/test";
import {
createTestAdminAndSignIn,
createTestUserWithPendingJoinRequestAndSignIn,
expectPageRedirectsUnlessApprovedMember,
} from "./testUtils";

test.describe("Home route", () => {
test("redirects guests from / to /login", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/login/);
test("redirects guests and pending join requests away from home", async ({
page,
}) => {
await expectPageRedirectsUnlessApprovedMember(page, "/");
});

test("redirects users with a pending join request to /joinrequeststatus", async ({
test("pending join request users see status copy on joinrequeststatus", async ({
page,
}) => {
await createTestUserWithPendingJoinRequestAndSignIn(page);
await page.goto("/");
await expect(page).toHaveURL(/\/joinrequeststatus/);
await expect(page.getByTestId("join-request-status-heading")).toBeVisible();
});

Expand Down
9 changes: 8 additions & 1 deletion tests/e2e/login.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { test, expect } from "@playwright/test";
import { createTestUserAndSignIn } from "./testUtils";
import {
createTestUserAndSignIn,
expectPageDoesNotRedirect,
} from "./testUtils";
import { buildTestUser, signUpAndGetSession } from "../unit/testUtils";

test.describe("Login Page", () => {
Expand All @@ -23,6 +26,10 @@ test.describe("Login Page", () => {
await expect(page).toHaveURL(/\//);
});

test("public static assets are not redirected by auth", async ({ page }) => {
await expectPageDoesNotRedirect(page, "/logo.svg");
});

test("login page background uses a linear gradient", async ({ page }) => {
await page.goto("/login");

Expand Down
13 changes: 9 additions & 4 deletions tests/e2e/members.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { test, expect } from "@playwright/test";
import { createTestAdminAndSignIn, createTestUserAndSignIn } from "./testUtils";
import {
createTestAdminAndSignIn,
createTestUserAndSignIn,
expectPageRedirectsUnlessAdmin,
} from "./testUtils";
import {
addMember,
buildTestUser,
Expand All @@ -9,9 +13,10 @@ import { OrganizationConfigService } from "@/lib/services/OrganizationConfigServ
import { OrganizationConfigKey } from "@/lib/schema";

test.describe("Members Page", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/members");
await expect(page).toHaveURL(/\/login/);
test("admin-only access: guests, pending join, and non-admins are redirected", async ({
page,
}) => {
await expectPageRedirectsUnlessAdmin(page, "/members");
});

test("redirects when authenticated but not an org member or admin", async ({
Expand Down
12 changes: 8 additions & 4 deletions tests/e2e/profile.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { test, expect } from "@playwright/test";
import { createTestUserAndSignIn } from "./testUtils";
import {
createTestUserAndSignIn,
expectPageRedirectsGuestButAllowsPendingJoin,
} from "./testUtils";

test.describe("Profile Page", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/profile");
await expect(page).toHaveURL(/\/login/);
test("requires sign-in but allows users with a pending join request", async ({
page,
}) => {
await expectPageRedirectsGuestButAllowsPendingJoin(page, "/profile");
});

test("shows user name and email when signed in", async ({ page }) => {
Expand Down
142 changes: 141 additions & 1 deletion tests/e2e/testUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import { eq } from "drizzle-orm";
import db from "@/lib/db";
import { organizations } from "@/lib/schema";
Expand Down Expand Up @@ -171,3 +171,143 @@ export async function createTestUserWithPendingJoinRequestAndSignIn(

return { user: credentials };
}

function normalizedPathname(url: string): string {
const pathname = new URL(url).pathname;
return pathname.length > 1 && pathname.endsWith("/")
? pathname.slice(0, -1)
: pathname;
}

function pathnamesEqual(
actualUrl: string | URL,
expectedPath: string,
): boolean {
const href = typeof actualUrl === "string" ? actualUrl : actualUrl.href;
const expected = expectedPath.startsWith("/")
? expectedPath
: `/${expectedPath}`;
return normalizedPathname(href) === normalizedPathname(expected);
}

/**
* Creates a test user, adds them as a non-admin member of the default host org
* (`servicestart`), sets the active organization on the session, and signs them in.
*/
export async function createTestMemberAndSignIn(
page: Page,
options: SignInOptions = {},
) {
const org = await ensureServicestartOrganization();
const user = buildTestUser();
const { session, headers } = await signUpAndGetSession(user);
await addMember(session.userId, org.id, "member");
await setActiveOrganization(session.id, org.id);

const cookieHeader = headers["Cookie"];
if (!cookieHeader) {
throw new Error("No auth cookie returned when signing up test user.");
}

const { name, value } = getCookieParts(cookieHeader);
const baseUrl =
options.baseUrl || process.env.BASE_URL || "http://localhost:3000";
const { hostname } = new URL(baseUrl);

await page.context().addCookies([
{
name,
value,
domain: hostname,
path: "/",
httpOnly: true,
sameSite: "Lax",
},
]);

return { user, org };
}

/**
* For routes that require an approved organization member: guests are sent to
* `/login`, and users with a pending join request for the host org are sent to
* `/joinrequeststatus`.
*/
export async function expectPageRedirectsUnlessApprovedMember(
page: Page,
path: string,
) {
await page.context().clearCookies();
await page.goto(path);
await expect(page).toHaveURL(/\/login/);

await createTestUserWithPendingJoinRequestAndSignIn(page);
await page.goto(path);
await expect(page).toHaveURL(/\/joinrequeststatus/);
}
Comment thread
krishpra2315 marked this conversation as resolved.

/**
* For routes that require sign-in but still allow users with a pending join
* request: guests are sent to `/login`; pending users remain on `path`.
*/
export async function expectPageRedirectsGuestButAllowsPendingJoin(
page: Page,
path: string,
) {
await page.context().clearCookies();
await page.goto(path);
await expect(page).toHaveURL(/\/login/);

await createTestUserWithPendingJoinRequestAndSignIn(page);
await page.goto(path);
await expect(page).toHaveURL((url) => pathnamesEqual(url, path));
}

/**
* For admin-only routes (same membership rules as `redirectIfNotAdmin` in authUtils):
* guests → `/login`, pending join → `/joinrequeststatus`, approved non-admins → `/`.
*/
export async function expectPageRedirectsUnlessAdmin(page: Page, path: string) {
await page.context().clearCookies();
await page.goto(path);
await expect(page).toHaveURL(/\/login/);

await createTestUserWithPendingJoinRequestAndSignIn(page);
await page.goto(path);
await expect(page).toHaveURL(/\/joinrequeststatus/);

await page.context().clearCookies();
await createTestMemberAndSignIn(page);
await page.goto(path);
await expect(page).toHaveURL((url) => pathnamesEqual(url, "/"));

await page.context().clearCookies();
await createTestAdminAndSignIn(page);
await page.goto(path);
await expect(page).toHaveURL((url) => pathnamesEqual(url, path));
}

/**
* Asserts that guests, pending-join users, members, and admins all remain on
* `path` after navigation (no auth redirect). Use for routes that do not gate
* on session (for example static files under `/public`).
*/
export async function expectPageDoesNotRedirect(page: Page, path: string) {
await page.context().clearCookies();
await page.goto(path);
await expect(page).toHaveURL((url) => pathnamesEqual(url, path));

await createTestUserWithPendingJoinRequestAndSignIn(page);
await page.goto(path);
await expect(page).toHaveURL((url) => pathnamesEqual(url, path));

await page.context().clearCookies();
await createTestMemberAndSignIn(page);
await page.goto(path);
await expect(page).toHaveURL((url) => pathnamesEqual(url, path));

await page.context().clearCookies();
await createTestAdminAndSignIn(page);
await page.goto(path);
await expect(page).toHaveURL((url) => pathnamesEqual(url, path));
}
Loading