import { type Page, type Locator } from '@playwright/test'; import { BasePage, waitForVisible, waitForHidden } from '@levkin/playkit'; export interface NewUserInput { email: string; password: string; name: string; isAdmin?: boolean; hasWriteAccess?: boolean; } /** * Page object for the "Manage Users" overlay content * (`viewer-frontend/app/admin/users/ManageUsersContent.tsx`). * * Talks to the NextAuth-backed `/api/users` store — a separate account * database from the FastAPI `/api/v1/users` store covered by * `api.role-permissions.spec.ts`. */ export class ManageUsersPanel extends BasePage { constructor(page: Page, baseUrl: string) { super(page, baseUrl); } row(email: string): Locator { return this.page.locator('tbody tr').filter({ hasText: email }); } /** The row's action buttons (Edit is always first; Deactivate/Delete is * second and only rendered for active users). Positional rather than * aria-label-based — proved more reliable against the live table than * exact accessible-name matching. */ private actionButton(email: string, position: 'first' | 'last'): Locator { const buttons = this.row(email).getByRole('button'); return position === 'first' ? buttons.first() : buttons.last(); } /** Scopes to the currently-open dialog matching a heading, to avoid * ambiguous matches against hidden/portalled sibling dialogs. */ private dialog(headingText: string): Locator { return this.page .getByRole('dialog') .filter({ has: this.page.getByRole('heading', { name: headingText, exact: true }) }); } async createUser(input: NewUserInput): Promise { await this.click(this.page.getByRole('button', { name: 'Add User', exact: true })); const dialog = this.dialog('Add New User'); await waitForVisible(dialog); await this.fill(dialog.locator('#add-email'), input.email); await this.fill(dialog.locator('#add-password'), input.password); await this.fill(dialog.locator('#add-name'), input.name); if (input.isAdmin) { await this.click(dialog.locator('#add-role')); await this.click(this.page.getByRole('option', { name: 'Admin', exact: true })); } if (input.hasWriteAccess) { await this.click(dialog.locator('#add-write-access')); } await this.click(dialog.getByRole('button', { name: 'Create User', exact: true })); await waitForVisible(this.row(input.email), { timeout: 15_000 }); } async openEditDialog(email: string): Promise { await this.click(this.actionButton(email, 'first')); await waitForVisible(this.dialog('Edit User')); } private async setCheckbox(locator: Locator, checked: boolean): Promise { const isChecked = await locator.isChecked(); if (isChecked !== checked) { await this.click(locator); } } async setActive(active: boolean): Promise { await this.setCheckbox(this.dialog('Edit User').locator('#edit-active'), active); } async setWriteAccess(enabled: boolean): Promise { await this.setCheckbox(this.dialog('Edit User').locator('#edit-write-access'), enabled); } async saveEdit(): Promise { const dialog = this.dialog('Edit User'); await this.click(dialog.getByRole('button', { name: 'Save Changes', exact: true })); await waitForHidden(dialog); } /** Clicks the row's delete/deactivate icon and confirms in the dialog. * Hard-deletes if the account has no related records, otherwise the * backend soft-deactivates it — either way `isActive` ends up false. */ async deleteUser(email: string): Promise { await this.click(this.actionButton(email, 'last')); const dialog = this.dialog('Delete User'); await waitForVisible(dialog); await this.click(dialog.getByRole('button', { name: 'Delete', exact: true })); await waitForHidden(dialog); } async statusBadgeText(email: string): Promise { return (await this.row(email).locator('td').nth(2).innerText()).trim(); } async roleBadgeText(email: string): Promise { return (await this.row(email).locator('td').nth(3).innerText()).trim(); } async writeAccessText(email: string): Promise { return (await this.row(email).locator('td').nth(4).innerText()).trim(); } async waitForRowGone(email: string, timeout = 15_000): Promise { await waitForHidden(this.row(email), { timeout }); } /** The table defaults to the "Active only" filter and refetches with it on * every save (`ManageUsersContent.handleEditUser` → `fetchUsers()`), so a * just-deactivated row silently drops out of view. Switch to "All" before * reading a status badge you expect to still be visible post-deactivation. */ async showAllUsers(): Promise { await this.click(this.page.locator('#status-filter')); await this.click(this.page.getByRole('option', { name: 'All', exact: true })); await waitForHidden(this.page.getByRole('option', { name: 'All', exact: true })); } }