1import type { Browser, BrowserContext, Locator, Page } from "@playwright/test";
2import { Api } from "../../helpers/api";
3import { expect, test, uid, usePersona } from "../../helpers/fixtures";
4import { PASSWORD } from "../../helpers/personas";
5import { loginContext } from "../../helpers/portal";
6import { snapshotSettings } from "../../helpers/settings";
7import { siteHeader } from "../../helpers/site";
8import { setOpendeskSetting } from "../../helpers/ticket";
9
10// Throwaway customers, so the shared personas never change.
11
12const PIXEL = "e2e/fixtures/pixel.png";
13const PORTAL_TOGGLES = [
14 "allow_customer_managers_to_invite",
15 "allow_customer_managers_to_manage_organization",
16];
17
18interface Person {
19 email: string;
20 contact: string;
21 fullName: string;
22}
23
24const contexts: BrowserContext[] = [];
25test.afterEach(async () => {
26 await Promise.all(contexts.splice(0).map((context) => context.close()));
27});
28
29async function newPerson(api: Api, domain: string, roles = ["OD Customer"], label = "Person"): Promise<Person> {
30 const id = uid();
31 const email = `${label.toLowerCase()}-${id}@${domain}`;
32 await api.insert("User", {
33 email,
34 first_name: label,
35 last_name: id,
36 send_welcome_email: 0,
37 user_type: "Website User",
38 new_password: PASSWORD,
39 roles: roles.map((role) => ({ role })),
40 });
41 const contact = await api.insert("Contact", {
42 first_name: label,
43 last_name: id,
44 email_id: email,
45 user: email,
46 email_ids: [{ email_id: email, is_primary: 1 }],
47 });
48 return { email, contact: contact.name, fullName: `${label} ${id}` };
49}
50
51async function newOrg(api: Api) {
52 const domain = `org-${uid()}.test`;
53 const owner = await newPerson(api, domain, ["OD Customer", "OD Customer Manager"], "Owner");
54 const manager = await newPerson(api, domain, ["OD Customer", "OD Customer Manager"], "Manager");
55 const member = await newPerson(api, domain, ["OD Customer"], "Member");
56 const org = await api.insert("OD Customer", {
57 customer_name: `E2E Org ${uid()}`,
58 domain,
59 country: "India",
60 primary_contact: owner.contact,
61 contacts: [
62 { contact_name: owner.contact, is_manager: 1 },
63 { contact_name: manager.contact, is_manager: 1 },
64 { contact_name: member.contact, is_manager: 0 },
65 ],
66 });
67 return { name: org.name as string, domain, owner, manager, member };
68}
69
70async function pageOf(browser: Browser, baseURL: string, email: string, password = PASSWORD, viewport?: { width: number; height: number }) {
71 const context = await browser.newContext({ baseURL, extraHTTPHeaders: siteHeader(), viewport });
72 contexts.push(context);
73 const response = await context.request.post("/api/method/login", { form: { usr: email, pwd: password } });
74 expect(response.ok(), `login ${email}`).toBeTruthy();
75 return context.newPage();
76}
77
78async function openSettingsAt(page: Page, path = "settings/profile") {
79 await page.goto(`/help/${path}`);
80 const dialog = page.getByRole("dialog", { name: "Settings" });
81 await expect(dialog).toBeVisible();
82 return dialog;
83}
84
85async function openOrgScreen(page: Page, org: string) {
86 const dialog = await openSettingsAt(page, `settings/members/${org}`);
87 await expect(dialog.getByText(org, { exact: true })).toBeVisible();
88 return dialog;
89}
90
91function memberRows(dialog: Locator) {
92 return dialog.locator("div.min-h-13");
93}
94
95function memberRow(dialog: Locator, text: string) {
96 return memberRows(dialog).filter({ hasText: text });
97}
98
99function toast(page: Page, text: string | RegExp) {
100 return page.getByText(text).first();
101}
102
103/** frappe-ui's Select is a listbox button, not a native select. */
104async function choose(page: Page, select: Locator, option: string) {
105 await select.click();
106 await page.getByRole("option", { name: option, exact: true }).click();
107}
108
109async function changeRole(page: Page, row: Locator, role: string) {
110 await row.getByRole("button", { name: "Member actions" }).click();
111 await page.getByRole("menuitem", { name: "Change role" }).hover();
112 await page.getByRole("menuitem", { name: role, exact: true }).click();
113}
114
115async function confirm(page: Page, title: string, button: string) {
116 const dialog = page.getByRole("dialog", { name: title });
117 await expect(dialog).toBeVisible();
118 await dialog.getByRole("button", { name: button, exact: true }).click();
119}
120
121test.describe("dialog", () => {
122 usePersona("customerManager");
123
124 test("S1 opens from the menu, follows each screen in the path, and closes", async ({ page }) => {
125 await page.goto("/help");
126 await page.getByRole("button", { name: /Opendesk/ }).first().click();
127 await page.getByRole("menuitem", { name: "Settings" }).click();
128 const dialog = page.getByRole("dialog", { name: "Settings" });
129 await expect(dialog.getByRole("heading", { name: "Profile" })).toBeVisible();
130 await expect(page).toHaveURL(/\/help\/settings\/profile$/);
131
132 await dialog.getByRole("tab", { name: "Manage Organization" }).click();
133 await expect(page).toHaveURL(/\/help\/settings\/members\/E2E%20Customer%20Org$/);
134 await page.goBack();
135 await expect(page).toHaveURL(/\/help\/settings\/profile$/);
136 await expect(dialog.getByRole("heading", { name: "Profile" })).toBeVisible();
137
138 await page.keyboard.press("Escape");
139 await expect(dialog).toBeHidden();
140 await expect(page).not.toHaveURL(/\/settings/);
141
142 await openSettingsAt(page);
143 await page.mouse.click(5, 300);
144 await expect(dialog).toBeHidden();
145 await expect(page).not.toHaveURL(/\/settings/);
146 });
147
148 test("S4 a customer's path to an admin tab shows nothing broken", async ({ page }) => {
149 const dialog = await openSettingsAt(page, "settings/knowledge-base");
150 await expect(dialog.getByRole("tab", { name: "Knowledge Base" })).toHaveCount(0);
151 await expect(dialog.getByText("Make knowledge base public")).toHaveCount(0);
152 await expect(dialog.getByRole("heading", { name: "Profile" }), "a tab you cannot see falls back to Profile").toBeVisible();
153 await openSettingsAt(page, "settings/portal-settings");
154 await expect(dialog.getByText("Allow member invites")).toHaveCount(0);
155 });
156
157 test("an old #settings link moves to the settings path", async ({ page }) => {
158 await page.goto("/help/my-tickets#settings/members/E2E Customer Org");
159 await expect(page).toHaveURL(/\/help\/my-tickets\/settings\/members\/E2E%20Customer%20Org$/);
160 await expect(page.getByRole("dialog", { name: "Settings" }).getByText("E2E Customer Org", { exact: true })).toBeVisible();
161 });
162});
163
164test.describe("guest", () => {
165 test.use({ storageState: { cookies: [], origins: [] } });
166
167 test("S3 a settings path on the public knowledge base", async ({ page, api }) => {
168 const restore = await setOpendeskSetting(api, "public_knowledge_base", 1);
169 try {
170 await page.goto("/help/settings/profile");
171 await expect(page.getByRole("heading", { name: "Here's what might help" })).toBeVisible();
172 await expect(page).toHaveURL(/\/help\/?$/);
173 await page.waitForLoadState("networkidle");
174 await expect(page.getByRole("dialog"), "a guest gets no settings dialog").toHaveCount(0);
175 } finally {
176 await restore();
177 }
178 });
179});
180
181test.describe("profile", () => {
182 test("S5–S12 rename, photo, preferences, theme, layout and password", async ({ api, browser, baseURL }) => {
183 const person = await newPerson(api, "example.com", ["OD Customer"], "Profile");
184 const page = await pageOf(browser, baseURL!, person.email);
185 const dialog = await openSettingsAt(page);
186
187 // S5 rename, Esc cancels
188 await dialog.getByRole("button", { name: "Edit name" }).click();
189 await dialog.getByRole("textbox").fill("Ignored Name");
190 await page.keyboard.press("Escape");
191 await expect(dialog).toBeVisible();
192 await expect(dialog.getByText(person.fullName, { exact: true })).toBeVisible();
193 await dialog.getByRole("button", { name: "Edit name" }).click();
194 await dialog.getByRole("textbox").fill("Renamed Walker Person");
195 await page.keyboard.press("Enter");
196 await expect(toast(page, "Profile updated")).toBeVisible();
197 await expect.poll(async () => (await api.get("User", person.email)).first_name).toBe("Renamed");
198 expect((await api.get("User", person.email)).last_name).toBe("Walker Person");
199
200 // S6 photo, failing upload first
201 const profileSaves: string[] = [];
202 page.on("request", (request) => request.url().includes("update_profile") && profileSaves.push(request.postData() || ""));
203 await page.route("**/api/method/upload_file", (route) => route.fulfill({ status: 500, body: "{}" }));
204 const failing = page.waitForEvent("filechooser");
205 await dialog.getByRole("button", { name: "Upload photo" }).click();
206 await (await failing).setFiles(PIXEL);
207 await expect(toast(page, "Could not upload the image")).toBeVisible();
208 await page.unroute("**/api/method/upload_file");
209 expect(profileSaves, "a failed upload saves nothing").toEqual([]);
210 const chooser = page.waitForEvent("filechooser");
211 await dialog.getByRole("button", { name: "Upload photo" }).click();
212 await (await chooser).setFiles(PIXEL);
213 await expect(toast(page, "Photo updated")).toBeVisible();
214 await expect.poll(async () => (await api.get("User", person.email)).user_image).toContain("/files/");
215 const self = await loginContext(baseURL!, person.email, PASSWORD);
216 const linked = await self.post("/api/method/opendesk.api.auth.update_profile", { data: { image: "https://example.com/x.png" } });
217 expect(linked.ok()).toBeFalsy();
218 expect(await linked.text()).toContain("Please upload the picture");
219
220 // S7 remove photo
221 await expect(dialog.getByRole("button", { name: "Change photo" })).toBeVisible();
222 await dialog.getByRole("button", { name: "Change photo" }).hover();
223 await dialog.getByRole("button", { name: "Remove photo" }).click();
224 await expect(toast(page, "Photo removed")).toBeVisible();
225 await expect.poll(async () => (await api.get("User", person.email)).user_image).toBeFalsy();
226
227 // S9 timezone
228 await dialog.getByRole("button", { name: /Asia\/Kolkata|Select timezone/ }).click();
229 await page.getByRole("combobox").last().fill("Europe/Berlin");
230 await page.getByRole("option", { name: "Europe/Berlin" }).click();
231 await expect.poll(async () => (await api.get("User", person.email)).time_zone).toBe("Europe/Berlin");
232 await expect(dialog.getByRole("button", { name: /Europe\/Berlin/ })).toBeVisible();
233
234 // S8 language
235 await dialog.getByRole("button", { name: "Select language" }).click();
236 await page.getByRole("combobox").last().fill("Deutsch");
237 await page.getByRole("option", { name: /Deutsch/ }).first().click();
238 await expect.poll(async () => (await api.get("User", person.email)).language).toBe("de");
239 const german = page.getByRole("dialog", { name: "Einstellungen" });
240 await expect(german.getByRole("heading", { name: "Profil", exact: true })).toBeVisible();
241 await german.getByRole("button", { name: /Deutsch/ }).click();
242 await page.getByRole("combobox").last().fill("English");
243 await page.getByRole("option", { name: "English", exact: true }).click();
244 await expect.poll(async () => (await api.get("User", person.email)).language).toBe("en");
245 await expect(dialog.getByRole("heading", { name: "Profile" })).toBeVisible();
246
247 // S10 theme
248 await choose(page, dialog.getByRole("combobox").first(), "Dark");
249 await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
250 await page.reload();
251 await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
252 await choose(page, dialog.getByRole("combobox").first(), "Light");
253 await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
254
255 // S11 conversation layout
256 await choose(page, dialog.getByRole("combobox").nth(1), "Chat");
257 expect(await page.evaluate(() => localStorage.getItem("kb:conversation-layout"))).toBe("chat");
258
259 // S12 password
260 await dialog.getByRole("button", { name: "Change password" }).click();
261 const change = page.getByRole("dialog", { name: "Change Password" });
262 const update = change.getByRole("button", { name: "Update" });
263 await change.getByPlaceholder("New Password").fill("short");
264 await expect(change.getByText("Password must be at least 8 characters")).toBeVisible();
265 const fresh = `E2e-Walk-${uid()}!`;
266 await change.getByPlaceholder("New Password").fill(fresh);
267 await change.getByPlaceholder("Confirm Password").fill(`${fresh}x`);
268 await expect(change.getByText("Passwords do not match")).toBeVisible();
269 await expect(update).toBeDisabled();
270 await change.getByPlaceholder("Confirm Password").fill(fresh);
271 await expect(change.getByText("Passwords match")).toBeVisible();
272 await update.click();
273 await expect(toast(page, "Password updated successfully.")).toBeVisible();
274 const relogin = await (await loginContext(baseURL!, person.email, fresh)).get("/api/method/frappe.auth.get_logged_user");
275 expect((await relogin.json()).message).toBe(person.email);
276 });
277
278 test("H7 the header theme toggle switches at once and keeps it", async ({ api, browser, baseURL }) => {
279 const person = await newPerson(api, "example.com", ["OD Customer"], "Theme");
280 const page = await pageOf(browser, baseURL!, person.email);
281 await page.goto("/help");
282 const before = await page.locator("html").getAttribute("data-theme");
283 await page.getByRole("button", { name: "Toggle theme" }).click();
284 const after = before === "dark" ? "light" : "dark";
285 await expect(page.locator("html")).toHaveAttribute("data-theme", after);
286 await page.reload();
287 await expect(page.locator("html")).toHaveAttribute("data-theme", after);
288 });
289});
290
291test.describe("organization", () => {
292 let restore: () => Promise<unknown>;
293 test.beforeAll(async ({ baseURL }) => {
294 const admin = await Api.login(baseURL!, "Administrator", process.env.ADMIN_PASSWORD || "admin");
295 restore = await snapshotSettings(admin, PORTAL_TOGGLES);
296 });
297 test.afterAll(async () => restore?.());
298
299 async function setToggles(api: Api, on: boolean) {
300 await api.update("OD Settings", "OD Settings", Object.fromEntries(PORTAL_TOGGLES.map((field) => [field, on ? 1 : 0])));
301 }
302
303 test("S13 the organization group follows membership and role", async ({ api, browser, baseURL }) => {
304 const org = await newOrg(api);
305 const loner = await newPerson(api, "example.com", ["OD Customer"], "Loner");
306
307 const lonerPage = await pageOf(browser, baseURL!, loner.email);
308 const lonerDialog = await openSettingsAt(lonerPage);
309 await expect(lonerDialog.getByText("Organization", { exact: true })).toHaveCount(0);
310
311 const memberDialog = await openSettingsAt(await pageOf(browser, baseURL!, org.member.email));
312 await expect(memberDialog.getByRole("tab", { name: "View Organization" })).toBeVisible();
313
314 const managerDialog = await openSettingsAt(await pageOf(browser, baseURL!, org.manager.email));
315 await expect(managerDialog.getByRole("tab", { name: "Manage Organization" })).toBeVisible();
316 });
317
318 test("S14–S16 one organization opens directly; several give a searchable grid", async ({ api, browser, baseURL }) => {
319 const org = await newOrg(api);
320 const page = await pageOf(browser, baseURL!, org.member.email);
321 let dialog = await openSettingsAt(page);
322 await dialog.getByRole("tab", { name: "View Organization" }).click();
323 await expect(dialog.getByText(org.name, { exact: true })).toBeVisible();
324 // S16 identity; name not editable
325 await expect(dialog.getByText(org.domain, { exact: true })).toBeVisible();
326 await expect(dialog.getByText("India")).toBeVisible();
327 await expect(dialog.getByRole("button", { name: "Edit name" })).toHaveCount(0);
328 // S14 no back chevron
329 await expect(dialog.locator('[data-component-id="kb_settings-organization-back"]')).toBeHidden();
330
331 // S15 a second organization
332 const second = await api.insert("OD Customer", {
333 customer_name: `E2E Org ${uid()}`,
334 domain: `second-${uid()}.test`,
335 contacts: [{ contact_name: org.member.contact, is_manager: 0 }],
336 });
337 dialog = await openSettingsAt(page, "settings/members");
338 await expect(dialog.getByRole("button", { name: new RegExp(org.name) })).toBeVisible();
339 const secondCard = dialog.getByRole("button", { name: new RegExp(second.name) });
340 await expect(secondCard).toContainText("Member");
341 await expect(secondCard).toContainText("0 tickets");
342 await expect(secondCard).toContainText("1 member");
343 await dialog.getByPlaceholder("Search").fill(second.domain);
344 await expect(dialog.getByRole("button", { name: new RegExp(org.name) })).toHaveCount(0);
345 await dialog.getByPlaceholder("Search").fill("zzz-nothing");
346 await expect(dialog.getByText("No organizations found")).toBeVisible();
347 await dialog.getByPlaceholder("Search").fill("");
348 await dialog.getByRole("button", { name: new RegExp(org.name) }).press("Enter");
349 await expect(page).toHaveURL(new RegExp(`/settings/members/${encodeURIComponent(org.name)}$`));
350 await dialog.locator('[data-component-id="kb_settings-organization-back"]').click();
351 await expect(dialog.getByRole("button", { name: new RegExp(second.name) })).toBeVisible();
352 });
353
354 test("S17 logo edits follow the setting and the manager role", async ({ api, browser, baseURL }) => {
355 const org = await newOrg(api);
356 await setToggles(api, false);
357 const managerPage = await pageOf(browser, baseURL!, org.manager.email);
358 let dialog = await openOrgScreen(managerPage, org.name);
359 await expect(dialog.getByRole("button", { name: "Upload photo" })).toHaveCount(0);
360 const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
361 const refused = await managerApi.post("/api/method/opendesk.api.customer.update_organization_image", { data: { customer: org.name, image: "" } });
362 expect(await refused.text()).toContain("You are not permitted to change the organization's logo");
363
364 await setToggles(api, true);
365 dialog = await openOrgScreen(managerPage, org.name);
366 const chooser = managerPage.waitForEvent("filechooser");
367 await dialog.getByRole("button", { name: "Upload photo" }).click();
368 await (await chooser).setFiles(PIXEL);
369 await expect(toast(managerPage, "Logo updated")).toBeVisible();
370 await expect.poll(async () => (await api.get("OD Customer", org.name)).image).toContain("/files/");
371 await dialog.getByRole("button", { name: "Change photo" }).hover();
372 await dialog.getByRole("button", { name: "Remove photo" }).click();
373 await expect(toast(managerPage, "Logo removed")).toBeVisible();
374 await expect.poll(async () => (await api.get("OD Customer", org.name)).image).toBeFalsy();
375
376 const memberApi = await loginContext(baseURL!, org.member.email, PASSWORD);
377 const notManager = await memberApi.post("/api/method/opendesk.api.customer.update_organization_image", { data: { customer: org.name, image: "" } });
378 expect(await notManager.text()).toContain(`You are not permitted to manage ${org.name}`);
379 const memberDialog = await openOrgScreen(await pageOf(browser, baseURL!, org.member.email), org.name);
380 await expect(memberDialog.getByRole("button", { name: "Upload photo" })).toHaveCount(0);
381 });
382
383 test("S18 S19 S27 member list: order, search, filters, last seen, member view", async ({ api, browser, baseURL }) => {
384 const org = await newOrg(api);
385 await setToggles(api, true);
386 const page = await pageOf(browser, baseURL!, org.manager.email);
387 const dialog = await openOrgScreen(page, org.name);
388 const names = await dialog.locator("div.grid div.text-base-medium").allInnerTexts();
389 // you first, then by role
390 expect(names.map((name) => name.split("\n")[0].trim())).toEqual([
391 org.manager.fullName,
392 org.owner.fullName,
393 org.member.fullName,
394 ]);
395 // S19 last seen: managers only
396 await expect(dialog.getByText("Last seen")).toBeVisible();
397 await expect(memberRow(dialog, org.owner.email)).toContainText("Never");
398
399 await dialog.getByPlaceholder("Search").fill(org.member.email);
400 await expect(memberRows(dialog)).toHaveCount(1);
401 await expect(memberRows(dialog)).toContainText(org.member.email);
402 await dialog.getByPlaceholder("Search").fill("zzz-nobody");
403 await expect(dialog.getByText("No members match this filter.")).toBeVisible();
404 await dialog.getByPlaceholder("Search").fill("");
405 const roleFilter = dialog.getByRole("combobox").last();
406 await roleFilter.click();
407 await expect(page.getByRole("option")).toHaveText(["All", "Manager", "Member"]);
408 await page.getByRole("option", { name: "Manager", exact: true }).click();
409 // The owner is its own role, so the Manager filter leaves it out.
410 await expect(memberRows(dialog)).toHaveCount(1);
411 await expect(memberRows(dialog)).toContainText(org.manager.email);
412 await expect(memberRow(dialog, org.member.email)).toHaveCount(0);
413
414 // S19 a phone drops the Last seen column
415 const phone = await pageOf(browser, baseURL!, org.manager.email, PASSWORD, { width: 390, height: 844 });
416 const phoneDialog = await openOrgScreen(phone, org.name);
417 await expect(memberRows(phoneDialog)).toHaveCount(3);
418 await expect(phoneDialog.getByText("Last seen")).toBeHidden();
419 // Not asserted: frappe-ui's SettingsDialog overflows a phone by 16px (upstream bug).
420
421 // S27 member view
422 const memberPage = await pageOf(browser, baseURL!, org.member.email);
423 const memberDialog = await openOrgScreen(memberPage, org.name);
424 await expect(memberRows(memberDialog)).toHaveCount(3);
425 await expect(memberDialog.getByRole("button", { name: "Invite people" })).toHaveCount(0);
426 await expect(memberDialog.getByRole("button", { name: "Member actions" })).toHaveCount(0);
427 await expect(memberDialog.getByText("Last seen")).toHaveCount(0);
428 await expect(memberDialog.locator("button").filter({ hasText: /^Manager$/ })).toHaveCount(0);
429 const memberApi = await loginContext(baseURL!, org.member.email, PASSWORD);
430 for (const [method, args] of [
431 ["invite_members", { emails: [`x-${uid()}@${org.domain}`], is_manager: false }],
432 ["update_member_role", { contact: org.manager.contact, is_manager: 0 }],
433 ["remove_member", { contact: org.manager.contact }],
434 ] as const) {
435 const response = await memberApi.post(`/api/method/opendesk.api.customer.${method}`, { data: { customer: org.name, ...args } });
436 expect(await response.text(), method).toContain(`You are not permitted to manage ${org.name}`);
437 }
438 });
439
440 test("S20 S21 role changes and removal", async ({ api, browser, baseURL }) => {
441 const org = await newOrg(api);
442 await setToggles(api, false);
443 const page = await pageOf(browser, baseURL!, org.manager.email);
444 let dialog = await openOrgScreen(page, org.name);
445 await expect(memberRow(dialog, org.member.email).getByRole("button")).toHaveCount(0);
446 await expect(dialog.getByRole("button", { name: "Member actions" })).toHaveCount(0);
447
448 await setToggles(api, true);
449 dialog = await openOrgScreen(page, org.name);
450 // never for the owner or yourself
451 await expect(memberRow(dialog, org.owner.email).getByRole("button")).toHaveCount(0);
452 await expect(memberRow(dialog, org.manager.email).getByRole("button")).toHaveCount(0);
453
454 await changeRole(page, memberRow(dialog, org.member.email), "Manager");
455 await confirm(page, "Grant manager access", "Confirm");
456 await expect(toast(page, "Role updated")).toBeVisible();
457 await expect
458 .poll(async () => (await api.get("User", org.member.email)).roles.map((row: any) => row.role))
459 .toContain("OD Customer Manager");
460 await changeRole(page, memberRow(dialog, org.member.email), "Member");
461 await confirm(page, "Revoke manager access", "Confirm");
462 await expect
463 .poll(async () => (await api.get("User", org.member.email)).roles.map((row: any) => row.role))
464 .not.toContain("OD Customer Manager");
465
466 const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
467 for (const contact of [org.owner.contact, org.manager.contact]) {
468 const response = await managerApi.post("/api/method/opendesk.api.customer.remove_member", { data: { customer: org.name, contact } });
469 expect(response.ok(), contact).toBeFalsy();
470 }
471
472 await memberRow(dialog, org.member.email).getByRole("button", { name: "Member actions" }).click();
473 await page.getByRole("menuitem", { name: "Remove from organization" }).click();
474 await confirm(page, "Remove member", "Remove");
475 await expect(toast(page, "Member removed")).toBeVisible();
476 await expect(memberRow(dialog, org.member.email)).toHaveCount(0);
477 const contacts = (await api.get("OD Customer", org.name)).contacts.map((row: any) => row.contact_name);
478 expect(contacts).not.toContain(org.member.contact);
479 });
480
481 test("S22–S26 invites: screen, errors, pending rows, cancel", async ({ api, browser, baseURL }) => {
482 const org = await newOrg(api);
483 await setToggles(api, true);
484 const page = await pageOf(browser, baseURL!, org.manager.email);
485 const dialog = await openOrgScreen(page, org.name);
486
487 await dialog.getByRole("button", { name: "Invite people" }).click();
488 await expect(page).toHaveURL(/\/invite$/);
489 await expect(dialog.getByText("Invite by email")).toBeVisible();
490 // S24 no emails, nothing to send
491 await expect(dialog.getByRole("button", { name: "Send Invites" })).toBeDisabled();
492 const input = dialog.getByPlaceholder("user1@example.com, user2@example.com, ...");
493 await input.click();
494 // S22 Enter adds each address as a chip
495 const first = `invitee-a-${uid()}@${org.domain}`;
496 const second = `invitee-b-${uid()}@${org.domain}`;
497 // Enter picks the highlighted row, which offers the typed address.
498 const addEmail = async (email: string) => {
499 await page.keyboard.type(email);
500 await expect(page.getByRole("option", { name: email }).first()).toBeVisible();
501 await page.keyboard.press("Enter");
502 };
503 await addEmail(first);
504 await addEmail(second);
505 await expect(dialog.getByText(first)).toBeVisible();
506 await expect(dialog.getByText(second)).toBeVisible();
507 await choose(page, dialog.locator('[data-component-id="kb_settings-invite-role"]'), "Manager");
508 await dialog.getByRole("button", { name: "Send Invites" }).click();
509 await expect(page.getByText(/invited successfully|Invitations created, but the emails could not be sent/).first()).toBeVisible();
510 const invitations = await api.list("User Invitation", { filters: { customer: org.name }, fields: ["email", "status"] });
511 expect(invitations.map((row) => row.email).sort()).toEqual([first, second].sort());
512 await expect(page).not.toHaveURL(/\/invite$/);
513
514 // S25 pending rows
515 const row = memberRow(dialog, first);
516 await expect(row).toContainText("by you");
517 await expect(row).toContainText("Pending");
518 await expect(row).toContainText("Manager");
519 // S22 an already invited email is skipped, not refused
520 await dialog.getByRole("button", { name: "Invite people" }).click();
521 await input.click();
522 await addEmail(first);
523 await dialog.getByRole("button", { name: "Send Invites" }).click();
524 await expect(toast(page, `Could not invite ${first}`)).toBeVisible();
525 await expect(page).not.toHaveURL(/\/invite$/);
526 // S26 no resend
527 await expect(dialog.getByRole("button", { name: /resend/i })).toHaveCount(0);
528 await dialog.getByRole("combobox").last().click();
529 await expect(page.getByRole("option", { name: "Invited" })).toBeVisible();
530 await page.keyboard.press("Escape");
531 await row.getByRole("button", { name: "Cancel invitation" }).click();
532 await confirm(page, "Cancel invitation", "Cancel invitation");
533 await expect(page.getByText(/Invitation cancelled/).first()).toBeVisible();
534 await expect(dialog.getByText(first)).toHaveCount(0);
535
536 // S22 the back chevron cancels the invite screen
537 await dialog.getByRole("button", { name: "Invite people" }).click();
538 await expect(page).toHaveURL(/\/invite$/);
539 await dialog.locator('[data-component-id="kb_settings-invite-back"]').click();
540 await expect(page).not.toHaveURL(/\/invite$/);
541
542 // S24 more than 20
543 const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
544 const tooMany = Array.from({ length: 21 }, (_, n) => `many-${n}-${uid()}@${org.domain}`);
545 const response = await managerApi.post("/api/method/opendesk.api.customer.invite_members", {
546 data: { customer: org.name, emails: tooMany, is_manager: false },
547 });
548 expect(await response.text()).toContain("You can invite up to 20 people at a time");
549 });
550
551 test("S2 a deep link opens the invite screen", async ({ api, browser, baseURL }) => {
552 const org = await newOrg(api);
553 await setToggles(api, true);
554 const page = await pageOf(browser, baseURL!, org.manager.email);
555 await page.goto(`/help/settings/members/${org.name}/invite`);
556 const dialog = page.getByRole("dialog", { name: "Settings" });
557 await expect(dialog.getByText("Invite by email")).toBeVisible();
558 await expect(dialog.getByRole("button", { name: "Send Invites" })).toBeVisible();
559 });
560
561 test("S24 invites are rate limited per user", async ({ api, baseURL }) => {
562 const org = await newOrg(api);
563 await setToggles(api, true);
564 const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
565 const statuses: number[] = [];
566 for (let n = 0; n < 11; n++) {
567 const response = await managerApi.post("/api/method/opendesk.api.customer.invite_members", {
568 data: { customer: org.name, emails: [`rate-${n}-${uid()}@${org.domain}`], is_manager: false },
569 });
570 statuses.push(response.status());
571 }
572 expect(statuses.slice(0, 10).every((status) => status !== 429)).toBeTruthy();
573 expect(statuses[10]).toBe(429);
574 });
575
576 test("S28 tickets tab: newest first, search, open, member sees own only", async ({ api, browser, baseURL }) => {
577 const org = await newOrg(api);
578 const managerApi = await loginContext(baseURL!, org.manager.email, PASSWORD);
579 const memberApi = await loginContext(baseURL!, org.member.email, PASSWORD);
580 const raise = async (context: typeof managerApi, subject: string) => {
581 const response = await context.post("/api/method/opendesk.opendesk.doctype.od_ticket.api.new", {
582 data: { doc: { subject, description: `<p>${subject}</p>`, customer: org.name } },
583 });
584 expect(response.ok(), await response.text()).toBeTruthy();
585 return (await response.json()).message;
586 };
587 const mine = await raise(managerApi, `Org manager ticket ${uid()}`);
588 const theirs = await raise(memberApi, `Org member ticket ${uid()}`);
589 for (const ticket of [mine, theirs]) await api.update("OD Ticket", ticket.name, { customer: org.name });
590
591 const page = await pageOf(browser, baseURL!, org.manager.email);
592 const dialog = await openOrgScreen(page, org.name);
593 await dialog.getByRole("tab", { name: "Tickets" }).click();
594 const links = dialog.getByRole("link");
595 await expect(links).toHaveCount(2);
596 await expect(links.first()).toContainText(`#${theirs.name}`);
597 await dialog.getByPlaceholder("Search").last().fill("member ticket");
598 await expect(links).toHaveCount(1);
599 await dialog.getByPlaceholder("Search").last().fill("zzz-none");
600 await expect(dialog.getByText("No tickets match your search.")).toBeVisible();
601 await dialog.getByPlaceholder("Search").last().fill("");
602 await links.first().click();
603 await expect(page).toHaveURL(new RegExp(`/help/my-tickets/${theirs.name}$`));
604
605 const memberDialog = await openOrgScreen(await pageOf(browser, baseURL!, org.member.email), org.name);
606 await memberDialog.getByRole("tab", { name: "Tickets" }).click();
607 await expect(memberDialog.getByRole("link")).toHaveCount(1);
608 await expect(memberDialog.getByRole("link")).toContainText(`#${theirs.name}`);
609
610 const empty = await newOrg(api);
611 const emptyDialog = await openOrgScreen(await pageOf(browser, baseURL!, empty.member.email), empty.name);
612 await emptyDialog.getByRole("tab", { name: "Tickets" }).click();
613 await expect(emptyDialog.getByText("No tickets from this organization yet.")).toBeVisible();
614 });
615});
616
617test.describe("customer portal settings", () => {
618 test("S29 the Customer Portal group shows only to settings editors", async ({ pageAs }) => {
619 for (const [key, shown] of [
620 ["admin", true],
621 ["manager", true],
622 ["agent", false],
623 ["customer", false],
624 ["customerManager", false],
625 ] as const) {
626 const page = await pageAs(key);
627 const dialog = await openSettingsAt(page);
628 await expect(dialog.getByRole("tab", { name: "Portal Settings" }), key).toHaveCount(shown ? 1 : 0);
629 }
630 });
631
632 test("S36 portal permission toggles save at once", async ({ api, pageAs }) => {
633 const restore = await snapshotSettings(api, PORTAL_TOGGLES);
634 const signup = (await api.get("Website Settings", "Website Settings")).disable_signup;
635 try {
636 const page = await pageAs("admin");
637 const dialog = await openSettingsAt(page, "settings/portal-settings");
638 await expect(dialog.getByText("Allow signup")).toBeVisible();
639 const labels = ["Allow member invites", "Allow organization management"];
640 for (const [index, label] of labels.entries()) {
641 const before = (await api.get("OD Settings", "OD Settings"))[PORTAL_TOGGLES[index]];
642 await dialog.getByRole("switch", { name: label }).click();
643 await expect(toast(page, "Settings updated")).toBeVisible();
644 await expect.poll(async () => (await api.get("OD Settings", "OD Settings"))[PORTAL_TOGGLES[index]]).toBe(before ? 0 : 1);
645 }
646 const managerDialog = await openSettingsAt(await pageAs("manager"), "settings/portal-settings");
647 await expect(managerDialog.getByText("Allow member invites")).toBeVisible();
648 await expect(managerDialog.getByText("Allow signup")).toHaveCount(0);
649 } finally {
650 await restore();
651 await api.update("Website Settings", "Website Settings", { disable_signup: signup });
652 }
653 });
654
655 test("S35 leaving the Knowledge Base tab with unsaved changes asks first", async ({ api, pageAs }) => {
656 const before = await api.get("OD Settings", "OD Settings");
657 const page = await pageAs("admin");
658 const dialog = await openSettingsAt(page, "settings/knowledge-base");
659 // Switches save at once, so the banner makes the edit.
660 await dialog.getByRole("radio", { name: before.banner_preset === "Blue" ? "None" : "Blue" }).click();
661 await expect(dialog.getByText("Unsaved", { exact: true })).toBeVisible();
662 await page.keyboard.press("Escape");
663 await expect(dialog).toBeVisible();
664 await dialog.getByRole("tab", { name: /Profile/ }).click();
665 await confirm(page, "Unsaved changes", "Confirm");
666 await expect(dialog.getByRole("heading", { name: "Profile" })).toBeVisible();
667 await page.keyboard.press("Escape");
668 await expect(dialog).toBeHidden();
669 const after = await api.get("OD Settings", "OD Settings");
670 expect(after.banner_preset).toBe(before.banner_preset);
671 });
672
673 test("S37 an agent in an organization sees it and counts as its manager", async ({ api, browser, baseURL }) => {
674 const restore = await snapshotSettings(api, PORTAL_TOGGLES);
675 try {
676 const org = await newOrg(api);
677 const agent = await newPerson(api, org.domain, ["Agent"], "Agent");
678 await api.update("User", agent.email, { user_type: "System User" });
679 await api.insert("OD Agent", { user: agent.email, agent_name: agent.fullName, is_active: 1 });
680 const plain = await newPerson(api, "example.com", ["Agent"], "Plainagent");
681 await api.update("User", plain.email, { user_type: "System User" });
682 await api.insert("OD Agent", { user: plain.email, agent_name: plain.fullName, is_active: 1 });
683 const customer = await api.get("OD Customer", org.name);
684 await api.update("OD Customer", org.name, {
685 contacts: [...customer.contacts.map((row: any) => ({ contact_name: row.contact_name, is_manager: row.is_manager })), { contact_name: agent.contact, is_manager: 0 }],
686 });
687 await api.update("OD Settings", "OD Settings", { allow_customer_managers_to_invite: 1 });
688
689 const page = await pageOf(browser, baseURL!, agent.email);
690 const dialog = await openSettingsAt(page);
691 const tab = dialog.getByRole("tab", { name: /Organization/ });
692 await expect(tab).toBeVisible();
693 await tab.click();
694 await expect(dialog.getByText(org.name, { exact: true })).toBeVisible();
695 // A plain Agent has no OD Customer write, so it is a member here.
696 await expect(dialog.getByRole("button", { name: "Invite people" })).toHaveCount(0);
697
698 // With OD Customer write (Agent Manager) the agent manages it.
699 await api.update("User", agent.email, { roles: [{ role: "Agent" }, { role: "Agent Manager" }] });
700 await page.reload();
701 await dialog.getByRole("tab", { name: /Organization/ }).click();
702 await expect(dialog.getByRole("button", { name: "Invite people" })).toBeVisible();
703 await expect(dialog.getByRole("tab", { name: /Organization/ }), "the label follows manage rights").toHaveText(/Manage Organization/);
704
705 const plainDialog = await openSettingsAt(await pageOf(browser, baseURL!, plain.email));
706 await expect(plainDialog.getByRole("tab", { name: /Organization/ })).toHaveCount(0);
707 } finally {
708 await restore();
709 }
710 });
711});
712