IKHSAN
ENID Baca dalam Bahasa Indonesia

opendesk

Public
Branch
main
Latest commit
08d9f3328 October 2026

portal-settings-walkthrough.spec.ts

Open raw
import type { Browser, BrowserContext, Locator, Page } from "@playwright/test";
import { Api } from "../../helpers/api";
import { expect, test, uid, usePersona } from "../../helpers/fixtures";
import { PASSWORD } from "../../helpers/personas";
import { loginContext } from "../../helpers/portal";
import { snapshotSettings } from "../../helpers/settings";
import { siteHeader } from "../../helpers/site";
import { setOpendeskSetting } from "../../helpers/ticket";

// Throwaway customers, so the shared personas never change.

const PIXEL = "e2e/fixtures/pixel.png";
const PORTAL_TOGGLES = [
  "allow_customer_managers_to_invite",
  "allow_customer_managers_to_manage_organization",
];

interface Person {
  email: string;
  contact: string;
  fullName: string;
}

const contexts: BrowserContext[] = [];
test.afterEach(async () => {
  await Promise.all(contexts.splice(0).map((context) => context.close()));
});

async function newPerson(api: Api, domain: string, roles = ["OD Customer"], label = "Person"): Promise<Person> {
  const id = uid();
  const email = `${label.toLowerCase()}-${id}@${domain}`;
  await api.insert("User", {
    email,
    first_name: label,
    last_name: id,
    send_welcome_email: 0,
    user_type: "Website User",
    new_password: PASSWORD,
    roles: roles.map((role) => ({ role })),
  });
  const contact = await api.insert("Contact", {
    first_name: label,
    last_name: id,
    email_id: email,
    user: email,
    email_ids: [{ email_id: email, is_primary: 1 }],
  });
  return { email, contact: contact.name, fullName: `${label} ${id}` };
}

async function newOrg(api: Api) {
  const domain = `org-${uid()}.test`;
  const owner = await newPerson(api, domain, ["OD Customer", "OD Customer Manager"], "Owner");
  const manager = await newPerson(api, domain, ["OD Customer", "OD Customer Manager"], "Manager");
  const member = await newPerson(api, domain, ["OD Customer"], "Member");
  const org = await api.insert("OD Customer", {
    customer_name: `E2E Org ${uid()}`,
    domain,
    country: "India",
    primary_contact: owner.contact,
    contacts: [
      { contact_name: owner.contact, is_manager: 1 },
      { contact_name: manager.contact, is_manager: 1 },
      { contact_name: member.contact, is_manager: 0 },
    ],
  });
  return { name: org.name as string, domain, owner, manager, member };
}

async function pageOf(browser: Browser, baseURL: string, email: string, password = PASSWORD, viewport?: { width: number; height: number }) {
  const context = await browser.newContext({ baseURL, extraHTTPHeaders: siteHeader(), viewport });
  contexts.push(context);
  const response = await context.request.post("/api/method/login", { form: { usr: email, pwd: password } });
  expect(response.ok(), `login ${email}`).toBeTruthy();
  return context.newPage();
}

async function openSettingsAt(page: Page, path = "settings/profile") {
  await page.goto(`/help/${path}`);
  const dialog = page.getByRole("dialog", { name: "Settings" });
  await expect(dialog).toBeVisible();
  return dialog;
}

async function openOrgScreen(page: Page, org: string) {
  const dialog = await openSettingsAt(page, `settings/members/${org}`);
  await expect(dialog.getByText(org, { exact: true })).toBeVisible();
  return dialog;
}

function memberRows(dialog: Locator) {
  return dialog.locator("div.min-h-13");
}

function memberRow(dialog: Locator, text: string) {
  return memberRows(dialog).filter({ hasText: text });
}

function toast(page: Page, text: string | RegExp) {
  return page.getByText(text).first();
}

/** frappe-ui's Select is a listbox button, not a native select. */
async function choose(page: Page, select: Locator, option: string) {
  await select.click();
  await page.getByRole("option", { name: option, exact: true }).click();
}

async function changeRole(page: Page, row: Locator, role: string) {
  await row.getByRole("button", { name: "Member actions" }).click();
  await page.getByRole("menuitem", { name: "Change role" }).hover();
  await page.getByRole("menuitem", { name: role, exact: true }).click();
}

async function confirm(page: Page, title: string, button: string) {
  const dialog = page.getByRole("dialog", { name: title });
  await expect(dialog).toBeVisible();
  await dialog.getByRole("button", { name: button, exact: true }).click();
}

test.describe("dialog", () => {
  usePersona("customerManager");

  test("S1 opens from the menu, follows each screen in the path, and closes", async ({ page }) => {
    await page.goto("/help");
    await page.getByRole("button", { name: /Opendesk/ }).first().click();
    await page.getByRole("menuitem", { name: "Settings" }).click();
    const dialog = page.getByRole("dialog", { name: "Settings" });
    await expect(dialog.getByRole("heading", { name: "Profile" })).toBeVisible();
    await expect(page).toHaveURL(/\/help\/settings\/profile$/);

    await dialog.getByRole("tab", { name: "Manage Organization" }).click();
    await expect(page).toHaveURL(/\/help\/settings\/members\/E2E%20Customer%20Org$/);
    await page.goBack();
    await expect(page).toHaveURL(/\/help\/settings\/profile$/);
    await expect(dialog.getByRole("heading", { name: "Profile" })).toBeVisible();

    await page.keyboard.press("Escape");
    await expect(dialog).toBeHidden();
    await expect(page).not.toHaveURL(/\/settings/);

    await openSettingsAt(page);
    await page.mouse.click(5, 300);
    await expect(dialog).toBeHidden();
    await expect(page).not.toHaveURL(/\/settings/);
  });

  test("S4 a customer's path to an admin tab shows nothing broken", async ({ page }) => {
    const dialog = await openSettingsAt(page, "settings/knowledge-base");
    await expect(dialog.getByRole("tab", { name: "Knowledge Base" })).toHaveCount(0);
    await expect(dialog.getByText("Make knowledge base public")).toHaveCount(0);
    await expect(dialog.getByRole("heading", { name: "Profile" }), "a tab you cannot see falls back to Profile").toBeVisible();
    await openSettingsAt(page, "settings/portal-settings");
    await expect(dialog.getByText("Allow member invites")).toHaveCount(0);
  });

  test("an old #settings link moves to the settings path", async ({ page }) => {
    await page.goto("/help/my-tickets#settings/members/E2E Customer Org");
    await expect(page).toHaveURL(/\/help\/my-tickets\/settings\/members\/E2E%20Customer%20Org$/);
    await expect(page.getByRole("dialog", { name: "Settings" }).getByText("E2E Customer Org", { exact: true })).toBeVisible();
  });
});

test.describe("guest", () => {
  test.use({ storageState: { cookies: [], origins: [] } });

  test("S3 a settings path on the public knowledge base", async ({ page, api }) => {
    const restore = await setOpendeskSetting(api, "public_knowledge_base", 1);
    try {
      await page.goto("/help/settings/profile");
      await expect(page.getByRole("heading", { name: "Here's what might help" })).toBeVisible();
      await expect(page).toHaveURL(/\/help\/?$/);
      await page.waitForLoadState("networkidle");
      await expect(page.getByRole("dialog"), "a guest gets no settings dialog").toHaveCount(0);
    } finally {
      await restore();
    }
  });
});

test.describe("profile", () => {
  test("S5–S12 rename, photo, preferences, theme, layout and password", async ({ api, browser, baseURL }) => {
    const person = await newPerson(api, "example.com", ["OD Customer"], "Profile");
    const page = await pageOf(browser, baseURL!, person.email);
    const dialog = await openSettingsAt(page);

    // S5 rename, Esc cancels
    await dialog.getByRole("button", { name: "Edit name" }).click();
    await dialog.getByRole("textbox").fill("Ignored Name");
    await page.keyboard.press("Escape");
    await expect(dialog).toBeVisible();
    await expect(dialog.getByText(person.fullName, { exact: true })).toBeVisible();
    await dialog.getByRole("button", { name: "Edit name" }).click();
    await dialog.getByRole("textbox").fill("Renamed Walker Person");
    await page.keyboard.press("Enter");
    await expect(toast(page, "Profile updated")).toBeVisible();
    await expect.poll(async () => (await api.get("User", person.email)).first_name).toBe("Renamed");
    expect((await api.get("User", person.email)).last_name).toBe("Walker Person");

    // S6 photo, failing upload first
    const profileSaves: string[] = [];
    page.on("request", (request) => request.url().includes("update_profile") && profileSaves.push(request.postData() || ""));
    await page.route("**/api/method/upload_file", (route) => route.fulfill({ status: 500, body: "{}" }));
    const failing = page.waitForEvent("filechooser");
    await dialog.getByRole("button", { name: "Upload photo" }).click();
    await (await failing).setFiles(PIXEL);
    await expect(toast(page, "Could not upload the image")).toBeVisible();
    await page.unroute("**/api/method/upload_file");
    expect(profileSaves, "a failed upload saves nothing").toEqual([]);
    const chooser = page.waitForEvent("filechooser");
    await dialog.getByRole("button", { name: "Upload photo" }).click();
    await (await chooser).setFiles(PIXEL);
    await expect(toast(page, "Photo updated")).toBeVisible();
    await expect.poll(async () => (await api.get("User", person.email)).user_image).toContain("/files/");
    const self = await loginContext(baseURL!, person.email, PASSWORD);
    const linked = await self.post("/api/method/opendesk.api.auth.update_profile", { data: { image: "https://example.com/x.png" } });
    expect(linked.ok()).toBeFalsy();
    expect(await linked.text()).toContain("Please upload the picture");

    // S7 remove photo
    await expect(dialog.getByRole("button", { name: "Change photo" })).toBeVisible();
    await dialog.getByRole("button", { name: "Change photo" }).hover();
    await dialog.getByRole("button", { name: "Remove photo" }).click();
    await expect(toast(page, "Photo removed")).toBeVisible();
    await expect.poll(async () => (await api.get("User", person.email)).user_image).toBeFalsy();

    // S9 timezone
    await dialog.getByRole("button", { name: /Asia\/Kolkata|Select timezone/ }).click();
    await page.getByRole("combobox").last().fill("Europe/Berlin");
    await page.getByRole("option", { name: "Europe/Berlin" }).click();
    await expect.poll(async () => (await api.get("User", person.email)).time_zone).toBe("Europe/Berlin");
    await expect(dialog.getByRole("button", { name: /Europe\/Berlin/ })).toBeVisible();

    // S8 language
    await dialog.getByRole("button", { name: "Select language" }).click();
    await page.getByRole("combobox").last().fill("Deutsch");
    await page.getByRole("option", { name: /Deutsch/ }).first().click();
    await expect.poll(async () => (await api.get("User", person.email)).language).toBe("de");
    const german = page.getByRole("dialog", { name: "Einstellungen" });
    await expect(german.getByRole("heading", { name: "Profil", exact: true })).toBeVisible();
    await german.getByRole("button", { name: /Deutsch/ }).click();
    await page.getByRole("combobox").last().fill("English");
    await page.getByRole("option", { name: "English", exact: true }).click();
    await expect.poll(async () => (await api.get("User", person.email)).language).toBe("en");
    await expect(dialog.getByRole("heading", { name: "Profile" })).toBeVisible();

    // S10 theme
    await choose(page, dialog.getByRole("combobox").first(), "Dark");
    await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
    await page.reload();
    await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
    await choose(page, dialog.getByRole("combobox").first(), "Light");
    await expect(page.locator("html")).toHaveAttribute("data-theme", "light");

    // S11 conversation layout
    await choose(page, dialog.getByRole("combobox").nth(1), "Chat");
    expect(await page.evaluate(() => localStorage.getItem("kb:conversation-layout"))).toBe("chat");

    // S12 password
    await dialog.getByRole("button", { name: "Change password" }).click();
    const change = page.getByRole("dialog", { name: "Change Password" });
    const update = change.getByRole("button", { name: "Update" });
    await change.getByPlaceholder("New Password").fill("short");
    await expect(change.getByText("Password must be at least 8 characters")).toBeVisible();
    const fresh = `E2e-Walk-${uid()}!`;
    await change.getByPlaceholder("New Password").fill(fresh);
    await change.getByPlaceholder("Confirm Password").fill(`${fresh}x`);
    await expect(change.getByText("Passwords do not match")).toBeVisible();
    await expect(update).toBeDisabled();
    await change.getByPlaceholder("Confirm Password").fill(fresh);
    await expect(change.getByText("Passwords match")).toBeVisible();
    await update.click();
    await expect(toast(page, "Password updated successfully.")).toBeVisible();
    const relogin = await (await loginContext(baseURL!, person.email, fresh)).get("/api/method/frappe.auth.get_logged_user");
    expect((await relogin.json()).message).toBe(person.email);
  });

  test("H7 the header theme toggle switches at once and keeps it", async ({ api, browser, baseURL }) => {
    const person = await newPerson(api, "example.com", ["OD Customer"], "Theme");
    const page = await pageOf(browser, baseURL!, person.email);
    await page.goto("/help");
    const before = await page.locator("html").getAttribute("data-theme");
    await page.getByRole("button", { name: "Toggle theme" }).click();
    const after = before === "dark" ? "light" : "dark";
    await expect(page.locator("html")).toHaveAttribute("data-theme", after);
    await page.reload();
    await expect(page.locator("html")).toHaveAttribute("data-theme", after);
  });
});

test.describe("organization", () => {
  let restore: () => Promise<unknown>;
  test.beforeAll(async ({ baseURL }) => {
    const admin = await Api.login(baseURL!, "Administrator", process.env.ADMIN_PASSWORD || "admin");
    restore = await snapshotSettings(admin, PORTAL_TOGGLES);
  });
  test.afterAll(async () => restore?.());

  async function setToggles(api: Api, on: boolean) {
    await api.update("OD Settings", "OD Settings", Object.fromEntries(PORTAL_TOGGLES.map((field) => [field, on ? 1 : 0])));
  }

  test("S13 the organization group follows membership and role", async ({ api, browser, baseURL }) => {
    const org = await newOrg(api);
    const loner = await newPerson(api, "example.com", ["OD Customer"], "Loner");

    const lonerPage = await pageOf(browser, baseURL!, loner.email);
    const lonerDialog = await openSettingsAt(lonerPage);
    await expect(lonerDialog.getByText("Organization", { exact: true })).toHaveCount(0);

    const memberDialog = await openSettingsAt(await pageOf(browser, baseURL!, org.member.email));
    await expect(memberDialog.getByRole("tab", { name: "View Organization" })).toBeVisible();

    const managerDialog = await openSettingsAt(await pageOf(browser, baseURL!, org.manager.email));
    await expect(managerDialog.getByRole("tab", { name: "Manage Organization" })).toBeVisible();
  });

  test("S14–S16 one organization opens directly; several give a searchable grid", async ({ api, browser, baseURL }) => {
    const org = await newOrg(api);
    const page = await pageOf(browser, baseURL!, org.member.email);
    let dialog = await openSettingsAt(page);
    await dialog.getByRole("tab", { name: "View Organization" }).click();
    await expect(dialog.getByText(org.name, { exact: true })).toBeVisible();
    // S16 identity; name not editable
    await expect(dialog.getByText(org.domain, { exact: true })).toBeVisible();
    await expect(dialog.getByText("India")).toBeVisible();
    await expect(dialog.getByRole("button", { name: "Edit name" })).toHaveCount(0);
    // S14 no back chevron
    await expect(dialog.locator('[data-component-id="kb_settings-organization-back"]')).toBeHidden();

    // S15 a second organization
    const second = await api.insert("OD Customer", {
      customer_name: `E2E Org ${uid()}`,
      domain: `second-${uid()}.test`,
      contacts: [{ contact_name: org.member.contact, is_manager: 0 }],
    });
    dialog = await openSettingsAt(page, "settings/members");
    await expect(dialog.getByRole("button", { name: new RegExp(org.name) })).toBeVisible();
    const secondCard = dialog.getByRole("button", { name: new RegExp(second.name) });
    await expect(secondCard).toContainText("Member");
    await expect(secondCard).toContainText("0 tickets");
    await expect(secondCard).toContainText("1 member");
    await dialog.getByPlaceholder("Search").fill(second.domain);
    await expect(dialog.getByRole("button", { name: new RegExp(org.name) })).toHaveCount(0);
    await dialog.getByPlaceholder("Search").fill("zzz-nothing");
    await expect(dialog.getByText("No organizations found")).toBeVisible();
    await dialog.getByPlaceholder("Search").fill("");
    await dialog.getByRole("button", { name: new RegExp(org.name) }).press("Enter");
    await expect(page).toHaveURL(new RegExp(`/settings/members/${encodeURIComponent(org.name)}$`));
    await dialog.locator('[data-component-id="kb_settings-organization-back"]').click();
    await expect(dialog.getByRole("button", { name: new RegExp(second.name) })).toBeVisible();
  });

  test("S17 logo edits follow the setting and the manager role", async ({ api, browser, baseURL }) => {
    const org = await newOrg(api);
    await setToggles(api, false);
    const managerPage = await pageOf(browser, baseURL!, org.manager.email);
    let dialog = await openOrgScreen(managerPage, org.name);
    await expect(dialog.getByRole("button", { name: "Upload photo" })).toHaveCount(0);
    const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
    const refused = await managerApi.post("/api/method/opendesk.api.customer.update_organization_image", { data: { customer: org.name, image: "" } });
    expect(await refused.text()).toContain("You are not permitted to change the organization's logo");

    await setToggles(api, true);
    dialog = await openOrgScreen(managerPage, org.name);
    const chooser = managerPage.waitForEvent("filechooser");
    await dialog.getByRole("button", { name: "Upload photo" }).click();
    await (await chooser).setFiles(PIXEL);
    await expect(toast(managerPage, "Logo updated")).toBeVisible();
    await expect.poll(async () => (await api.get("OD Customer", org.name)).image).toContain("/files/");
    await dialog.getByRole("button", { name: "Change photo" }).hover();
    await dialog.getByRole("button", { name: "Remove photo" }).click();
    await expect(toast(managerPage, "Logo removed")).toBeVisible();
    await expect.poll(async () => (await api.get("OD Customer", org.name)).image).toBeFalsy();

    const memberApi = await loginContext(baseURL!, org.member.email, PASSWORD);
    const notManager = await memberApi.post("/api/method/opendesk.api.customer.update_organization_image", { data: { customer: org.name, image: "" } });
    expect(await notManager.text()).toContain(`You are not permitted to manage ${org.name}`);
    const memberDialog = await openOrgScreen(await pageOf(browser, baseURL!, org.member.email), org.name);
    await expect(memberDialog.getByRole("button", { name: "Upload photo" })).toHaveCount(0);
  });

  test("S18 S19 S27 member list: order, search, filters, last seen, member view", async ({ api, browser, baseURL }) => {
    const org = await newOrg(api);
    await setToggles(api, true);
    const page = await pageOf(browser, baseURL!, org.manager.email);
    const dialog = await openOrgScreen(page, org.name);
    const names = await dialog.locator("div.grid div.text-base-medium").allInnerTexts();
    // you first, then by role
    expect(names.map((name) => name.split("\n")[0].trim())).toEqual([
      org.manager.fullName,
      org.owner.fullName,
      org.member.fullName,
    ]);
    // S19 last seen: managers only
    await expect(dialog.getByText("Last seen")).toBeVisible();
    await expect(memberRow(dialog, org.owner.email)).toContainText("Never");

    await dialog.getByPlaceholder("Search").fill(org.member.email);
    await expect(memberRows(dialog)).toHaveCount(1);
    await expect(memberRows(dialog)).toContainText(org.member.email);
    await dialog.getByPlaceholder("Search").fill("zzz-nobody");
    await expect(dialog.getByText("No members match this filter.")).toBeVisible();
    await dialog.getByPlaceholder("Search").fill("");
    const roleFilter = dialog.getByRole("combobox").last();
    await roleFilter.click();
    await expect(page.getByRole("option")).toHaveText(["All", "Manager", "Member"]);
    await page.getByRole("option", { name: "Manager", exact: true }).click();
    // The owner is its own role, so the Manager filter leaves it out.
    await expect(memberRows(dialog)).toHaveCount(1);
    await expect(memberRows(dialog)).toContainText(org.manager.email);
    await expect(memberRow(dialog, org.member.email)).toHaveCount(0);

    // S19 a phone drops the Last seen column
    const phone = await pageOf(browser, baseURL!, org.manager.email, PASSWORD, { width: 390, height: 844 });
    const phoneDialog = await openOrgScreen(phone, org.name);
    await expect(memberRows(phoneDialog)).toHaveCount(3);
    await expect(phoneDialog.getByText("Last seen")).toBeHidden();
    // Not asserted: frappe-ui's SettingsDialog overflows a phone by 16px (upstream bug).

    // S27 member view
    const memberPage = await pageOf(browser, baseURL!, org.member.email);
    const memberDialog = await openOrgScreen(memberPage, org.name);
    await expect(memberRows(memberDialog)).toHaveCount(3);
    await expect(memberDialog.getByRole("button", { name: "Invite people" })).toHaveCount(0);
    await expect(memberDialog.getByRole("button", { name: "Member actions" })).toHaveCount(0);
    await expect(memberDialog.getByText("Last seen")).toHaveCount(0);
    await expect(memberDialog.locator("button").filter({ hasText: /^Manager$/ })).toHaveCount(0);
    const memberApi = await loginContext(baseURL!, org.member.email, PASSWORD);
    for (const [method, args] of [
      ["invite_members", { emails: [`x-${uid()}@${org.domain}`], is_manager: false }],
      ["update_member_role", { contact: org.manager.contact, is_manager: 0 }],
      ["remove_member", { contact: org.manager.contact }],
    ] as const) {
      const response = await memberApi.post(`/api/method/opendesk.api.customer.${method}`, { data: { customer: org.name, ...args } });
      expect(await response.text(), method).toContain(`You are not permitted to manage ${org.name}`);
    }
  });

  test("S20 S21 role changes and removal", async ({ api, browser, baseURL }) => {
    const org = await newOrg(api);
    await setToggles(api, false);
    const page = await pageOf(browser, baseURL!, org.manager.email);
    let dialog = await openOrgScreen(page, org.name);
    await expect(memberRow(dialog, org.member.email).getByRole("button")).toHaveCount(0);
    await expect(dialog.getByRole("button", { name: "Member actions" })).toHaveCount(0);

    await setToggles(api, true);
    dialog = await openOrgScreen(page, org.name);
    // never for the owner or yourself
    await expect(memberRow(dialog, org.owner.email).getByRole("button")).toHaveCount(0);
    await expect(memberRow(dialog, org.manager.email).getByRole("button")).toHaveCount(0);

    await changeRole(page, memberRow(dialog, org.member.email), "Manager");
    await confirm(page, "Grant manager access", "Confirm");
    await expect(toast(page, "Role updated")).toBeVisible();
    await expect
      .poll(async () => (await api.get("User", org.member.email)).roles.map((row: any) => row.role))
      .toContain("OD Customer Manager");
    await changeRole(page, memberRow(dialog, org.member.email), "Member");
    await confirm(page, "Revoke manager access", "Confirm");
    await expect
      .poll(async () => (await api.get("User", org.member.email)).roles.map((row: any) => row.role))
      .not.toContain("OD Customer Manager");

    const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
    for (const contact of [org.owner.contact, org.manager.contact]) {
      const response = await managerApi.post("/api/method/opendesk.api.customer.remove_member", { data: { customer: org.name, contact } });
      expect(response.ok(), contact).toBeFalsy();
    }

    await memberRow(dialog, org.member.email).getByRole("button", { name: "Member actions" }).click();
    await page.getByRole("menuitem", { name: "Remove from organization" }).click();
    await confirm(page, "Remove member", "Remove");
    await expect(toast(page, "Member removed")).toBeVisible();
    await expect(memberRow(dialog, org.member.email)).toHaveCount(0);
    const contacts = (await api.get("OD Customer", org.name)).contacts.map((row: any) => row.contact_name);
    expect(contacts).not.toContain(org.member.contact);
  });

  test("S22–S26 invites: screen, errors, pending rows, cancel", async ({ api, browser, baseURL }) => {
    const org = await newOrg(api);
    await setToggles(api, true);
    const page = await pageOf(browser, baseURL!, org.manager.email);
    const dialog = await openOrgScreen(page, org.name);

    await dialog.getByRole("button", { name: "Invite people" }).click();
    await expect(page).toHaveURL(/\/invite$/);
    await expect(dialog.getByText("Invite by email")).toBeVisible();
    // S24 no emails, nothing to send
    await expect(dialog.getByRole("button", { name: "Send Invites" })).toBeDisabled();
    const input = dialog.getByPlaceholder("user1@example.com, user2@example.com, ...");
    await input.click();
    // S22 Enter adds each address as a chip
    const first = `invitee-a-${uid()}@${org.domain}`;
    const second = `invitee-b-${uid()}@${org.domain}`;
    // Enter picks the highlighted row, which offers the typed address.
    const addEmail = async (email: string) => {
      await page.keyboard.type(email);
      await expect(page.getByRole("option", { name: email }).first()).toBeVisible();
      await page.keyboard.press("Enter");
    };
    await addEmail(first);
    await addEmail(second);
    await expect(dialog.getByText(first)).toBeVisible();
    await expect(dialog.getByText(second)).toBeVisible();
    await choose(page, dialog.locator('[data-component-id="kb_settings-invite-role"]'), "Manager");
    await dialog.getByRole("button", { name: "Send Invites" }).click();
    await expect(page.getByText(/invited successfully|Invitations created, but the emails could not be sent/).first()).toBeVisible();
    const invitations = await api.list("User Invitation", { filters: { customer: org.name }, fields: ["email", "status"] });
    expect(invitations.map((row) => row.email).sort()).toEqual([first, second].sort());
    await expect(page).not.toHaveURL(/\/invite$/);

    // S25 pending rows
    const row = memberRow(dialog, first);
    await expect(row).toContainText("by you");
    await expect(row).toContainText("Pending");
    await expect(row).toContainText("Manager");
    // S22 an already invited email is skipped, not refused
    await dialog.getByRole("button", { name: "Invite people" }).click();
    await input.click();
    await addEmail(first);
    await dialog.getByRole("button", { name: "Send Invites" }).click();
    await expect(toast(page, `Could not invite ${first}`)).toBeVisible();
    await expect(page).not.toHaveURL(/\/invite$/);
    // S26 no resend
    await expect(dialog.getByRole("button", { name: /resend/i })).toHaveCount(0);
    await dialog.getByRole("combobox").last().click();
    await expect(page.getByRole("option", { name: "Invited" })).toBeVisible();
    await page.keyboard.press("Escape");
    await row.getByRole("button", { name: "Cancel invitation" }).click();
    await confirm(page, "Cancel invitation", "Cancel invitation");
    await expect(page.getByText(/Invitation cancelled/).first()).toBeVisible();
    await expect(dialog.getByText(first)).toHaveCount(0);

    // S22 the back chevron cancels the invite screen
    await dialog.getByRole("button", { name: "Invite people" }).click();
    await expect(page).toHaveURL(/\/invite$/);
    await dialog.locator('[data-component-id="kb_settings-invite-back"]').click();
    await expect(page).not.toHaveURL(/\/invite$/);

    // S24 more than 20
    const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
    const tooMany = Array.from({ length: 21 }, (_, n) => `many-${n}-${uid()}@${org.domain}`);
    const response = await managerApi.post("/api/method/opendesk.api.customer.invite_members", {
      data: { customer: org.name, emails: tooMany, is_manager: false },
    });
    expect(await response.text()).toContain("You can invite up to 20 people at a time");
  });

  test("S2 a deep link opens the invite screen", async ({ api, browser, baseURL }) => {
    const org = await newOrg(api);
    await setToggles(api, true);
    const page = await pageOf(browser, baseURL!, org.manager.email);
    await page.goto(`/help/settings/members/${org.name}/invite`);
    const dialog = page.getByRole("dialog", { name: "Settings" });
    await expect(dialog.getByText("Invite by email")).toBeVisible();
    await expect(dialog.getByRole("button", { name: "Send Invites" })).toBeVisible();
  });

  test("S24 invites are rate limited per user", async ({ api, baseURL }) => {
    const org = await newOrg(api);
    await setToggles(api, true);
    const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
    const statuses: number[] = [];
    for (let n = 0; n < 11; n++) {
      const response = await managerApi.post("/api/method/opendesk.api.customer.invite_members", {
        data: { customer: org.name, emails: [`rate-${n}-${uid()}@${org.domain}`], is_manager: false },
      });
      statuses.push(response.status());
    }
    expect(statuses.slice(0, 10).every((status) => status !== 429)).toBeTruthy();
    expect(statuses[10]).toBe(429);
  });

  test("S28 tickets tab: newest first, search, open, member sees own only", async ({ api, browser, baseURL }) => {
    const org = await newOrg(api);
    const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
    const memberApi = await loginContext(baseURL!, org.member.email, PASSWORD);
    const raise = async (context: typeof managerApi, subject: string) => {
      const response = await context.post("/api/method/opendesk.opendesk.doctype.od_ticket.api.new", {
        data: { doc: { subject, description: `<p>${subject}</p>`, customer: org.name } },
      });
      expect(response.ok(), await response.text()).toBeTruthy();
      return (await response.json()).message;
    };
    const mine = await raise(managerApi, `Org manager ticket ${uid()}`);
    const theirs = await raise(memberApi, `Org member ticket ${uid()}`);
    for (const ticket of [mine, theirs]) await api.update("OD Ticket", ticket.name, { customer: org.name });

    const page = await pageOf(browser, baseURL!, org.manager.email);
    const dialog = await openOrgScreen(page, org.name);
    await dialog.getByRole("tab", { name: "Tickets" }).click();
    const links = dialog.getByRole("link");
    await expect(links).toHaveCount(2);
    await expect(links.first()).toContainText(`#${theirs.name}`);
    await dialog.getByPlaceholder("Search").last().fill("member ticket");
    await expect(links).toHaveCount(1);
    await dialog.getByPlaceholder("Search").last().fill("zzz-none");
    await expect(dialog.getByText("No tickets match your search.")).toBeVisible();
    await dialog.getByPlaceholder("Search").last().fill("");
    await links.first().click();
    await expect(page).toHaveURL(new RegExp(`/help/my-tickets/${theirs.name}$`));

    const memberDialog = await openOrgScreen(await pageOf(browser, baseURL!, org.member.email), org.name);
    await memberDialog.getByRole("tab", { name: "Tickets" }).click();
    await expect(memberDialog.getByRole("link")).toHaveCount(1);
    await expect(memberDialog.getByRole("link")).toContainText(`#${theirs.name}`);

    const empty = await newOrg(api);
    const emptyDialog = await openOrgScreen(await pageOf(browser, baseURL!, empty.member.email), empty.name);
    await emptyDialog.getByRole("tab", { name: "Tickets" }).click();
    await expect(emptyDialog.getByText("No tickets from this organization yet.")).toBeVisible();
  });
});

test.describe("customer portal settings", () => {
  test("S29 the Customer Portal group shows only to settings editors", async ({ pageAs }) => {
    for (const [key, shown] of [
      ["admin", true],
      ["manager", true],
      ["agent", false],
      ["customer", false],
      ["customerManager", false],
    ] as const) {
      const page = await pageAs(key);
      const dialog = await openSettingsAt(page);
      await expect(dialog.getByRole("tab", { name: "Portal Settings" }), key).toHaveCount(shown ? 1 : 0);
    }
  });

  test("S36 portal permission toggles save at once", async ({ api, pageAs }) => {
    const restore = await snapshotSettings(api, PORTAL_TOGGLES);
    const signup = (await api.get("Website Settings", "Website Settings")).disable_signup;
    try {
      const page = await pageAs("admin");
      const dialog = await openSettingsAt(page, "settings/portal-settings");
      await expect(dialog.getByText("Allow signup")).toBeVisible();
      const labels = ["Allow member invites", "Allow organization management"];
      for (const [index, label] of labels.entries()) {
        const before = (await api.get("OD Settings", "OD Settings"))[PORTAL_TOGGLES[index]];
        await dialog.getByRole("switch", { name: label }).click();
        await expect(toast(page, "Settings updated")).toBeVisible();
        await expect.poll(async () => (await api.get("OD Settings", "OD Settings"))[PORTAL_TOGGLES[index]]).toBe(before ? 0 : 1);
      }
      const managerDialog = await openSettingsAt(await pageAs("manager"), "settings/portal-settings");
      await expect(managerDialog.getByText("Allow member invites")).toBeVisible();
      await expect(managerDialog.getByText("Allow signup")).toHaveCount(0);
    } finally {
      await restore();
      await api.update("Website Settings", "Website Settings", { disable_signup: signup });
    }
  });

  test("S35 leaving the Knowledge Base tab with unsaved changes asks first", async ({ api, pageAs }) => {
    const before = await api.get("OD Settings", "OD Settings");
    const page = await pageAs("admin");
    const dialog = await openSettingsAt(page, "settings/knowledge-base");
    // Switches save at once, so the banner makes the edit.
    await dialog.getByRole("radio", { name: before.banner_preset === "Blue" ? "None" : "Blue" }).click();
    await expect(dialog.getByText("Unsaved", { exact: true })).toBeVisible();
    await page.keyboard.press("Escape");
    await expect(dialog).toBeVisible();
    await dialog.getByRole("tab", { name: /Profile/ }).click();
    await confirm(page, "Unsaved changes", "Confirm");
    await expect(dialog.getByRole("heading", { name: "Profile" })).toBeVisible();
    await page.keyboard.press("Escape");
    await expect(dialog).toBeHidden();
    const after = await api.get("OD Settings", "OD Settings");
    expect(after.banner_preset).toBe(before.banner_preset);
  });

  test("S37 an agent in an organization sees it and counts as its manager", async ({ api, browser, baseURL }) => {
    const restore = await snapshotSettings(api, PORTAL_TOGGLES);
    try {
      const org = await newOrg(api);
      const agent = await newPerson(api, org.domain, ["Agent"], "Agent");
      await api.update("User", agent.email, { user_type: "System User" });
      await api.insert("OD Agent", { user: agent.email, agent_name: agent.fullName, is_active: 1 });
      const plain = await newPerson(api, "example.com", ["Agent"], "Plainagent");
      await api.update("User", plain.email, { user_type: "System User" });
      await api.insert("OD Agent", { user: plain.email, agent_name: plain.fullName, is_active: 1 });
      const customer = await api.get("OD Customer", org.name);
      await api.update("OD Customer", org.name, {
        contacts: [...customer.contacts.map((row: any) => ({ contact_name: row.contact_name, is_manager: row.is_manager })), { contact_name: agent.contact, is_manager: 0 }],
      });
      await api.update("OD Settings", "OD Settings", { allow_customer_managers_to_invite: 1 });

      const page = await pageOf(browser, baseURL!, agent.email);
      const dialog = await openSettingsAt(page);
      const tab = dialog.getByRole("tab", { name: /Organization/ });
      await expect(tab).toBeVisible();
      await tab.click();
      await expect(dialog.getByText(org.name, { exact: true })).toBeVisible();
      // A plain Agent has no OD Customer write, so it is a member here.
      await expect(dialog.getByRole("button", { name: "Invite people" })).toHaveCount(0);

      // With OD Customer write (Agent Manager) the agent manages it.
      await api.update("User", agent.email, { roles: [{ role: "Agent" }, { role: "Agent Manager" }] });
      await page.reload();
      await dialog.getByRole("tab", { name: /Organization/ }).click();
      await expect(dialog.getByRole("button", { name: "Invite people" })).toBeVisible();
      await expect(dialog.getByRole("tab", { name: /Organization/ }), "the label follows manage rights").toHaveText(/Manage Organization/);

      const plainDialog = await openSettingsAt(await pageOf(browser, baseURL!, plain.email));
      await expect(plainDialog.getByRole("tab", { name: /Organization/ })).toHaveCount(0);
    } finally {
      await restore();
    }
  });
});
bytes
37,572
lines
712
Type
ts
Latest commit
28 October 2026