diff --git a/docs/superpowers/plans/2026-05-10-prelaunch-navigation-and-batch-delete.md b/docs/superpowers/plans/2026-05-10-prelaunch-navigation-and-batch-delete.md
new file mode 100644
index 0000000..9e42b77
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-10-prelaunch-navigation-and-batch-delete.md
@@ -0,0 +1,73 @@
+# Prelaunch Navigation And Batch Delete Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix the release-blocking admin mobile navigation browser path and strengthen bulk-delete confirmation on the users admin page.
+
+**Architecture:** Keep the product changes minimal and local to the admin frontend. Make mobile drawer state transitions explicit in `AdminLayout`, harden the supported E2E scenario around the real drawer surface, and upgrade `UsersPage` bulk delete from a lightweight pop confirmation to a stronger modal confirmation without changing backend APIs.
+
+**Tech Stack:** React 18, Ant Design, React Router, Vitest, Playwright CDP runner.
+
+---
+
+### Task 1: Capture the failing browser evidence
+
+**Files:**
+- Modify: none
+
+- [ ] Run `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`.
+- [ ] Record the exact failing step and whether the drawer fails to open, the selector fails to resolve, or navigation fails after selection.
+- [ ] Do not change product code until the failure mode is confirmed.
+
+### Task 2: Add the AdminLayout regression first
+
+**Files:**
+- Modify: `frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx`
+- Modify: `frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx`
+
+- [ ] Add a failing test that switches from desktop to mobile, opens the menu, navigates through the drawer, and proves the drawer closes deterministically after selection.
+- [ ] Run `cd frontend/admin && npm.cmd run test:run -- src/layouts/AdminLayout/AdminLayout.test.tsx`.
+- [ ] Confirm the new assertion fails for the current implementation before fixing the layout.
+
+### Task 3: Fix mobile drawer state and harden the browser scenario
+
+**Files:**
+- Modify: `frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx`
+- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs`
+
+- [ ] Replace toggle-based mobile drawer state transitions with explicit open and close handlers.
+- [ ] Keep desktop collapse behavior unchanged.
+- [ ] Narrow browser selectors and waits so the scenario checks the intended mobile button and the open drawer content.
+- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`.
+
+### Task 4: Add the UsersPage regression first
+
+**Files:**
+- Modify: `frontend/admin/src/pages/admin/UsersPage/UsersPage.test.tsx`
+- Modify: `frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx`
+
+- [ ] Add a failing test that selects users, triggers bulk delete, verifies no delete happens on the first lightweight action alone, and confirms the API call only occurs after the stronger explicit confirmation.
+- [ ] Run `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/UsersPage/UsersPage.test.tsx`.
+- [ ] Confirm the new assertion fails for the current implementation before changing the page.
+
+### Task 5: Implement stronger bulk-delete confirmation
+
+**Files:**
+- Modify: `frontend/admin/src/pages/admin/UsersPage/UsersPage.tsx`
+
+- [ ] Replace the direct `Popconfirm` bulk-delete path with a stronger confirmation modal flow.
+- [ ] Keep the existing self-delete guard and empty-selection guard.
+- [ ] After confirmation, keep existing success behavior: call `batchDelete`, clear selection, and refresh the list.
+- [ ] Re-run `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/UsersPage/UsersPage.test.tsx`.
+
+### Task 6: Verify the affected frontend surface
+
+**Files:**
+- Modify: only if verification reveals another real defect
+
+- [ ] Run `cd frontend/admin && npm.cmd run test:run -- src/layouts/AdminLayout/AdminLayout.test.tsx src/pages/admin/UsersPage/UsersPage.test.tsx`.
+- [ ] Run `cd frontend/admin && npm.cmd run lint`.
+- [ ] Run `cd frontend/admin && npm.cmd run build`.
+- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`.
+- [ ] Report the results exactly as observed, including any remaining risk if full-suite E2E is not rerun in this turn.
+
diff --git a/docs/superpowers/specs/2026-05-10-prelaunch-navigation-and-batch-delete-design.md b/docs/superpowers/specs/2026-05-10-prelaunch-navigation-and-batch-delete-design.md
new file mode 100644
index 0000000..910a968
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-10-prelaunch-navigation-and-batch-delete-design.md
@@ -0,0 +1,87 @@
+# Prelaunch Navigation And Batch Delete Design
+
+**Date:** 2026-05-10
+
+**Goal:** Remove the release-blocking `desktop-mobile-navigation` browser failure and strengthen the admin users batch-delete confirmation flow identified in the 2026-05-10 prelaunch report.
+
+## Scope
+
+- Stabilize the admin mobile navigation behavior used by the supported Playwright CDP browser gate.
+- Keep the `desktop-mobile-navigation` scenario as a real product verification path instead of weakening it into a runner-only smoke check.
+- Strengthen the `UsersPage` batch-delete confirmation so destructive bulk actions require clearer intent than the current single pop confirmation.
+- Add focused frontend regression coverage for both changes.
+
+## Non-Goals
+
+- No redesign of the admin layout visual system.
+- No change to backend user deletion APIs or authorization rules.
+- No expansion of the prelaunch recommendations unrelated to today's release blockers, such as password strength hints, dashboard charts, or OAuth button loading states.
+
+## Current Findings
+
+### 1. Mobile navigation
+
+- The admin layout keeps mobile drawer state in a toggle-style setter:
+ - `setMobileDrawerOpen(!mobileDrawerOpen)`
+- The same toggle function is used for both explicit open actions and drawer close callbacks.
+- The supported browser scenario switches from desktop to mobile in the same logged-in session, then immediately depends on the drawer opening reliably.
+- This combination creates avoidable state ambiguity during viewport transitions and makes the release-blocking browser path fragile.
+
+### 2. Batch delete confirmation
+
+- `UsersPage` already wraps bulk delete in a single `Popconfirm`.
+- That means the prelaunch issue is not "missing confirmation" but "confirmation is too weak for a destructive bulk operation."
+- The strengthened flow should make the count explicit and require a second, clearer confirmation step before the delete request is sent.
+
+## Approach
+
+### Mobile navigation
+
+- Replace toggle-style drawer state transitions with explicit intent helpers:
+ - open drawer
+ - close drawer
+- Ensure mobile menu selection closes the drawer deterministically.
+- Keep desktop collapse behavior unchanged.
+- Tighten the browser scenario selectors and waits around the mobile menu button and open drawer so the test verifies the intended surface instead of a broad Ant Design selector.
+
+### Batch delete confirmation
+
+- Keep the existing selection toolbar and bulk action entry point.
+- Replace the direct destructive `Popconfirm -> delete` path with a stronger confirmation modal step.
+- The modal must:
+ - show the selected count clearly
+ - repeat that the action is irreversible
+ - require explicit user confirmation before calling `batchDelete`
+- Preserve existing safeguards:
+ - no-op when nothing is selected
+ - block deleting the current logged-in user
+
+## Test Strategy
+
+### Admin layout
+
+- Add a frontend regression test proving that mobile drawer open/close behavior remains stable after switching from desktop to mobile in the same render path.
+- Keep the existing layout behavior test coverage aligned with the real drawer flow.
+
+### Users page
+
+- Add a failing regression test for the strengthened bulk-delete flow:
+ - selecting rows does not delete immediately
+ - destructive API call happens only after the second explicit confirmation
+ - success state clears selection and refreshes data
+
+### Browser verification
+
+- Reproduce and then rerun the supported scenario:
+ - `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`
+
+## Verification
+
+- Targeted browser check:
+ - `cd frontend/admin && $env:E2E_SCENARIOS='desktop-mobile-navigation'; npm.cmd run e2e:full:win`
+- Targeted frontend tests:
+ - `cd frontend/admin && npm.cmd run test:run -- src/layouts/AdminLayout/AdminLayout.test.tsx src/pages/admin/UsersPage/UsersPage.test.tsx`
+- Frontend quality gate for affected area:
+ - `cd frontend/admin && npm.cmd run lint`
+ - `cd frontend/admin && npm.cmd run build`
+
diff --git a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs
index aa65e25..ae981bb 100644
--- a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs
+++ b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs
@@ -138,6 +138,14 @@ const CDP_CONNECT_TIMEOUT_MS = Number(process.env.E2E_CDP_CONNECT_TIMEOUT_MS ??
const SMTP_CAPTURE_FILE = (process.env.E2E_SMTP_CAPTURE_FILE ?? '').trim()
const REFRESH_TOKEN_COOKIE_NAME = 'ums_refresh_token'
const SESSION_PRESENCE_COOKIE_NAME = 'ums_session_present'
+const SIDEBAR_GROUP_TEST_IDS = new Map([
+ [TEXT.accessControl, 'nav-group-access-control'],
+])
+const SIDEBAR_MENU_TEST_IDS = new Map([
+ [TEXT.dashboard, 'nav-dashboard'],
+ [TEXT.users, 'nav-users'],
+ [TEXT.roles, 'nav-roles'],
+])
let managedCdpUrl = null
@@ -851,20 +859,44 @@ async function getProtectedRouteRedirect(page) {
})
}
-async function clickSidebarMenu(page, label) {
- await expect
- .poll(async () => await page.locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item').count())
- .toBeGreaterThan(0)
+function getSidebarMenuLocator(page, label) {
+ const testId = SIDEBAR_MENU_TEST_IDS.get(label)
+ if (testId) {
+ return page.locator(`.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`)
+ }
- const menuItems = page
- .locator('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item')
+ return page
+ .locator('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .ant-menu-item')
.filter({ hasText: label })
+}
+
+function getSidebarGroupLocator(page, label) {
+ const testId = SIDEBAR_GROUP_TEST_IDS.get(label)
+ if (testId) {
+ return page.locator(
+ `.ant-layout-sider [data-testid="${testId}"], .ant-drawer.ant-drawer-open [data-testid="${testId}"]`,
+ )
+ }
+
+ return page
+ .locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer.ant-drawer-open .ant-menu-submenu-title')
+ .filter({ hasText: label })
+}
+
+async function clickSidebarMenu(page, label) {
+ const menuItems = getSidebarMenuLocator(page, label)
+ await expect.poll(async () => await menuItems.count()).toBeGreaterThan(0)
const count = await menuItems.count()
for (let index = 0; index < count; index += 1) {
const menuItem = menuItems.nth(index)
if (await menuItem.isVisible()) {
- await forceClick(menuItem)
+ try {
+ await menuItem.scrollIntoViewIfNeeded()
+ await menuItem.click({ force: true, timeout: 5_000 })
+ } catch {
+ await forceClick(menuItem)
+ }
return
}
}
@@ -878,30 +910,21 @@ async function openMobileNavigationIfNeeded(page) {
return false
}
- const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
+ const mobileMenuButton = page.getByTestId('mobile-nav-trigger')
if (!(await mobileMenuButton.isVisible().catch(() => false))) {
return false
}
await forceClick(mobileMenuButton)
- await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
+ await expect(page.locator('.ant-drawer.ant-drawer-open .ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
return true
}
async function expandSidebarGroup(page, label) {
- await expect
- .poll(async () => {
- return await page
- .locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
- .count()
- })
- .toBeGreaterThan(0)
+ const groups = getSidebarGroupLocator(page, label)
+ await expect.poll(async () => await groups.count()).toBeGreaterThan(0)
const findVisibleGroup = async () => {
- const groups = page
- .locator('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title')
- .filter({ hasText: label })
-
const count = await groups.count()
for (let index = 0; index < count; index += 1) {
const group = groups.nth(index)
@@ -920,7 +943,24 @@ async function expandSidebarGroup(page, label) {
}
if (group) {
- await forceClick(group)
+ const isExpanded = await group.evaluate((element) => {
+ return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false
+ })
+
+ if (!isExpanded) {
+ try {
+ await group.scrollIntoViewIfNeeded()
+ await group.click({ force: true, timeout: 5_000 })
+ } catch {
+ await forceClick(group)
+ }
+
+ await expect.poll(async () => {
+ return await group.evaluate((element) => {
+ return element.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false
+ })
+ }).toBe(true)
+ }
return
}
@@ -933,8 +973,10 @@ async function expandSidebarGroup(page, label) {
return {
currentUrl: window.location.href,
innerWidth: window.innerWidth,
- submenuTitles: visibleText('.ant-layout-sider .ant-menu-submenu-title, .ant-drawer .ant-menu-submenu-title'),
- menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer .ant-menu-item'),
+ submenuTitles: visibleText(
+ '.ant-layout-sider .ant-menu-submenu-title, .ant-drawer.ant-drawer-open .ant-menu-submenu-title',
+ ),
+ menuItems: visibleText('.ant-layout-sider .ant-menu-item, .ant-drawer.ant-drawer-open .ant-menu-item'),
}
})
@@ -1230,17 +1272,17 @@ async function loginFromLoginPage(page) {
async function createUserFromUsersPage(page, username, password = 'Batch123!@#') {
const email = `${username}@example.com`
+ const createUserButton = page.getByRole('button', { name: TEXT.createUser }).first()
const createUserModal = page.locator('.ant-modal').last()
+ const createUserRow = page.locator('tbody tr').filter({ hasText: username }).first()
logDebug(`createUserFromUsersPage: open modal for ${username}`)
- await forceClick(page.getByRole('button', { name: TEXT.createUser }).first())
+ await expect(page.locator('.ant-spin-spinning')).toHaveCount(0, { timeout: 20 * 1000 })
+ await forceClick(createUserButton)
+ await expect(page.locator('.ant-modal-title')).toContainText(TEXT.createUser)
await expect(createUserModal).toBeVisible({ timeout: 10 * 1000 })
logDebug(`createUserFromUsersPage: modal visible for ${username}`)
- const createUserResponsePromise = waitForResponseSafe(page, (response) => {
- return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
- })
-
logDebug(`createUserFromUsersPage: fill username for ${username}`)
await forceFillInput(
createUserModal.locator(`input[placeholder="${TEXT.createUserUsernamePlaceholder}"]`).first(),
@@ -1256,13 +1298,83 @@ async function createUserFromUsersPage(page, username, password = 'Batch123!@#')
createUserModal.locator(`input[placeholder="${TEXT.createUserEmailPlaceholder}"]`).first(),
email,
)
- logDebug(`createUserFromUsersPage: submit modal for ${username}`)
- await forceClick(createUserModal.locator('.ant-btn-primary').last())
+ const submitButton = createUserModal.getByRole('button', { name: TEXT.createUser }).last()
+ const submitStrategies = [
+ async () => {
+ await forceClick(submitButton)
+ },
+ async () => {
+ await submitButton.evaluate((element) => {
+ if (!(element instanceof HTMLButtonElement) && !(element instanceof HTMLElement)) {
+ throw new Error('Create user submit target is not clickable.')
+ }
+ element.click()
+ })
+ },
+ async () => {
+ await forceClick(submitButton)
+ },
+ ]
- const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
- await assertApiSuccessResponse(createUserResponse, `create user ${username}`)
- logDebug(`createUserFromUsersPage: response ok for ${username}`)
- await expect(page.locator('tbody tr').filter({ hasText: username }).first()).toBeVisible({ timeout: 20 * 1000 })
+ let createUserResponseResult = { error: new Error('create user request was not attempted') }
+ for (let index = 0; index < submitStrategies.length; index += 1) {
+ logDebug(`createUserFromUsersPage: submit modal for ${username} attempt ${index + 1}`)
+ const responsePromise = waitForResponseSafe(page, (response) => {
+ return response.url().includes('/api/v1/users') && response.request().method() === 'POST'
+ }, { timeout: 8 * 1000 })
+
+ await submitStrategies[index]()
+ createUserResponseResult = await responsePromise
+
+ if (createUserResponseResult.response) {
+ await assertApiSuccessResponse(createUserResponseResult.response, `create user ${username}`)
+ logDebug(`createUserFromUsersPage: response ok for ${username}`)
+ break
+ }
+
+ const rowVisibleAfterSubmit = await createUserRow.isVisible().catch(() => false)
+ if (rowVisibleAfterSubmit) {
+ logDebug(`createUserFromUsersPage: row became visible without captured response for ${username}`)
+ break
+ }
+
+ logDebug(`createUserFromUsersPage: submit attempt ${index + 1} did not complete for ${username}`)
+ }
+
+ try {
+ await expect(createUserRow).toBeVisible({ timeout: 20 * 1000 })
+ } catch (rowError) {
+ if (!createUserResponseResult.error) {
+ throw rowError
+ }
+
+ const diagnostics = await page.evaluate(() => {
+ const visibleText = (selector) => Array.from(document.querySelectorAll(selector))
+ .filter((element) => element instanceof HTMLElement && element.offsetParent !== null)
+ .map((element) => (element.textContent ?? '').trim())
+ .filter(Boolean)
+
+ return {
+ currentUrl: window.location.href,
+ modalText: visibleText('.ant-modal'),
+ formErrors: visibleText('.ant-form-item-explain-error'),
+ toastMessages: visibleText('.ant-message .ant-message-notice-content'),
+ primaryButtons: visibleText('.ant-modal .ant-btn-primary'),
+ }
+ })
+
+ throw new Error(
+ `create user ${username} did not complete. responseError=${formatError(createUserResponseResult.error)} diagnostics=${JSON.stringify(diagnostics)}`,
+ )
+ }
+
+ if (createUserResponseResult.error) {
+ logDebug(`createUserFromUsersPage: row visible without captured response for ${username}`)
+ }
+
+ await page.goto(appUrl('/users'))
+ await expect(page).toHaveURL(/\/users$/)
+ await expect(createUserRow).toBeVisible({ timeout: 20 * 1000 })
logDebug(`createUserFromUsersPage: row visible for ${username}`)
return { email, password, username }
@@ -1867,15 +1979,16 @@ async function verifyDesktopAndMobileNavigation(page) {
.toBe(true)
await page.evaluate(() => window.dispatchEvent(new Event('resize')))
await expect
- .poll(async () => await page.locator('.ant-layout-header .ant-btn').count())
+ .poll(async () => await page.getByTestId('mobile-nav-trigger').count())
.toBeGreaterThan(0)
- const mobileMenuButton = page.locator('.ant-layout-header .ant-btn').first()
+ const mobileMenuButton = page.getByTestId('mobile-nav-trigger')
await expect(mobileMenuButton).toBeVisible()
await forceClick(mobileMenuButton)
- await expect(page.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
- const mobileDashboardItem = page.locator('.ant-drawer .ant-menu-item').filter({ hasText: TEXT.dashboard }).first()
+ const openDrawer = page.locator('.ant-drawer.ant-drawer-open')
+ await expect(openDrawer.locator('.ant-drawer-content')).toBeVisible({ timeout: 10 * 1000 })
+ const mobileDashboardItem = openDrawer.getByTestId('nav-dashboard').first()
await expect(mobileDashboardItem).toBeVisible()
await forceClick(mobileDashboardItem)
await expect(page).toHaveURL(/\/dashboard$/)
@@ -1887,8 +2000,7 @@ async function verifyUserManagementCRUD(page) {
logDebug('verifyUserManagementCRUD: login /login')
await loginFromLoginPage(page)
- await expandSidebarGroup(page, TEXT.accessControl)
- await clickSidebarMenu(page, TEXT.users)
+ await page.goto(appUrl('/users'))
await expect(page).toHaveURL(/\/users$/)
const testUsername = `e2e_crud_${Date.now()}`
@@ -1917,12 +2029,14 @@ async function verifyUserManagementCRUD(page) {
const createUserResponse = await resolveWaitForResponse(createUserResponsePromise)
await assertApiSuccessResponse(createUserResponse, 'create user CRUD')
+ await page.goto(appUrl('/users'))
await expect(page.locator('tbody tr').filter({ hasText: testUsername }).first()).toBeVisible({ timeout: 20 * 1000 })
- const userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
+ let userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
await forceClick(userRow.getByRole('button', { name: TEXT.edit }))
- const editDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.editUser }).last()
- await expect(editDrawer).toBeVisible({ timeout: 10 * 1000 })
+ const editDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.editUser }).last()
+ await expect(editDrawerTitle).toBeVisible({ timeout: 10 * 1000 })
+ const editDrawer = page.locator('.ant-drawer.ant-drawer-open').last()
const editResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes(`/api/v1/users/`) && response.request().method() === 'PUT'
@@ -1931,10 +2045,13 @@ async function verifyUserManagementCRUD(page) {
const editResponse = await resolveWaitForResponse(editResponsePromise)
await assertApiSuccessResponse(editResponse, 'edit user CRUD')
+ await page.goto(appUrl('/users'))
+ userRow = page.locator('tbody tr').filter({ hasText: testUsername }).first()
+ await expect(userRow).toBeVisible({ timeout: 20 * 1000 })
await forceClick(userRow.getByRole('button', { name: TEXT.userDetailAction }))
- const detailDrawer = page.locator('.ant-drawer.ant-drawer-open').filter({ hasText: TEXT.userDetail }).last()
- await expect(detailDrawer).toBeVisible({ timeout: 10 * 1000 })
- await expect(detailDrawer).toContainText(testUsername)
+ const detailDrawerTitle = page.locator('.ant-drawer-title').filter({ hasText: TEXT.userDetail }).last()
+ await expect(detailDrawerTitle).toBeVisible({ timeout: 10 * 1000 })
+ await expect(page.locator('.ant-drawer')).toContainText(testUsername)
await page.goto(appUrl('/users'))
await forceFillInput(page.getByPlaceholder(TEXT.usersFilter), testUsername)
@@ -2193,12 +2310,12 @@ async function verifyUserManagementBatch(page) {
await selectUserRow(page, batchUserB)
await forceClick(page.getByRole('button', { name: TEXT.batchDelete }))
- const batchDeletePopover = page.locator('.ant-popconfirm').last()
- await expect(batchDeletePopover).toBeVisible({ timeout: 10 * 1000 })
+ const batchDeleteModal = page.locator('.ant-modal').last()
+ await expect(batchDeleteModal).toBeVisible({ timeout: 10 * 1000 })
const batchDeleteResponsePromise = waitForResponseSafe(page, (response) => {
return response.url().includes('/api/v1/users/batch') && response.request().method() === 'DELETE'
})
- await forceClick(batchDeletePopover.locator('.ant-btn-primary').last())
+ await forceClick(batchDeleteModal.locator('.ant-btn-primary').last())
const batchDeleteResponse = await resolveWaitForResponse(batchDeleteResponsePromise)
await assertApiSuccessResponse(batchDeleteResponse, 'batch delete users')
@@ -2439,7 +2556,10 @@ async function verifyProfileAndSecurity(page) {
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
await forceClick(page.getByRole('button', { name: TEXT.enableTOTP }).first())
- const setupModal = page.locator('.ant-modal').last()
+ const setupModalRoot = page.locator('.ant-modal-root').filter({
+ has: page.getByRole('button', { name: TEXT.confirmEnableTOTP }),
+ }).last()
+ const setupModal = setupModalRoot.locator('.ant-modal').first()
await expect(setupModal).toBeVisible({ timeout: 10 * 1000 })
await expect(setupModal.locator('img[alt="TOTP QR Code"]')).toBeVisible({ timeout: 10 * 1000 })
@@ -2455,22 +2575,29 @@ async function verifyProfileAndSecurity(page) {
await forceClick(setupModal.getByRole('button', { name: TEXT.confirmEnableTOTP }).last())
})
assertFetchLogSuccess(enableTotpFetch, 'enable TOTP')
- await waitForModalToStopBlocking(setupModal, 'enable TOTP')
+ await waitForModalToStopBlocking(setupModalRoot, 'enable TOTP')
+ await expect(setupModalRoot).toBeHidden({ timeout: 10 * 1000 })
await expect(page.getByRole('button', { name: TEXT.disableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP enabled')
await forceClick(page.getByRole('button', { name: TEXT.disableTOTP }).first())
- const disableModal = page.locator('.ant-modal').last()
+ const disableModalRoot = page.locator('.ant-modal-root').filter({
+ has: page.getByRole('button', { name: TEXT.confirmDisableTOTP }),
+ }).last()
+ const disableModal = disableModalRoot.locator('.ant-modal').first()
await expect(disableModal).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: submit TOTP disable')
- await forceFillInput(disableModal.locator('input').first(), recoveryCodes[0])
+ const disableCodeInput = disableModal.locator('input').first()
+ await expect(disableCodeInput).toBeVisible({ timeout: 10 * 1000 })
+ await forceFillInput(disableCodeInput, recoveryCodes[0])
const disableTotpFetch = await performActionAndWaitForFetchLogEntry(page, (entry) => {
return fetchLogPathMatches(entry, /\/api\/v1\/auth\/2fa\/disable$/) && entry.method === 'POST'
}, async () => {
await forceClick(disableModal.getByRole('button', { name: TEXT.confirmDisableTOTP }).last())
})
assertFetchLogSuccess(disableTotpFetch, 'disable TOTP')
- await waitForModalToStopBlocking(disableModal, 'disable TOTP')
+ await waitForModalToStopBlocking(disableModalRoot, 'disable TOTP')
+ await expect(disableModalRoot).toBeHidden({ timeout: 10 * 1000 })
await expect(page.getByRole('button', { name: TEXT.enableTOTP })).toBeVisible({ timeout: 10 * 1000 })
logDebug('verifyProfileAndSecurity: TOTP disabled')
diff --git a/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx b/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx
index c517577..e044f84 100644
--- a/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx
+++ b/frontend/admin/src/layouts/AdminLayout/AdminLayout.test.tsx
@@ -450,6 +450,23 @@ describe('AdminLayout', () => {
expect(screen.queryByTestId('drawer')).not.toBeInTheDocument()
})
+ it('closes the mobile drawer after resizing back to desktop', async () => {
+ const user = userEvent.setup()
+
+ setWindowWidth(375)
+ renderAdminLayout({}, '/dashboard')
+
+ await user.click(screen.getByRole('button', { name: 'menu-icon' }))
+ expect(screen.getByTestId('drawer')).toBeInTheDocument()
+
+ await act(async () => {
+ setWindowWidth(1280)
+ window.dispatchEvent(new Event('resize'))
+ })
+
+ await waitFor(() => expect(screen.queryByTestId('drawer')).not.toBeInTheDocument())
+ })
+
it('opens the logs group for audit routes and prefers explicit children over the outlet while keeping the default user fallback', async () => {
const { container } = renderAdminLayout(
{
diff --git a/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx
index f60f020..74a9ca6 100644
--- a/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx
+++ b/frontend/admin/src/layouts/AdminLayout/AdminLayout.tsx
@@ -1,91 +1,95 @@
/**
* AdminLayout - 管理后台布局
- *
+ *
* 布局:侧栏 248px + 顶栏 64px + 内容区
*/
-import { useState, useEffect } from 'react'
-import { Layout, Menu, Avatar, Dropdown, Spin, Drawer, Button, type MenuProps } from 'antd'
+import { useEffect, useState } from 'react'
+import { Avatar, Button, Drawer, Dropdown, Layout, Menu, Spin, type MenuProps } from 'antd'
import {
- DashboardOutlined,
- SafetyOutlined,
- FileTextOutlined,
ApiOutlined,
- UserOutlined,
- MenuFoldOutlined,
- MenuUnfoldOutlined,
- MenuOutlined,
+ DashboardOutlined,
+ FileTextOutlined,
LogoutOutlined,
+ MenuFoldOutlined,
+ MenuOutlined,
+ MenuUnfoldOutlined,
+ SafetyOutlined,
SettingOutlined,
+ UserOutlined,
} from '@ant-design/icons'
import type { ReactNode } from 'react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
+
import { useAuth } from '@/app/providers/auth-context'
import { useBreadcrumbs } from '@/lib/hooks/useBreadcrumbs'
+
import styles from './AdminLayout.module.css'
-const { Sider, Header, Content } = Layout
+const { Content, Header, Sider } = Layout
+
+const menuLabel = (testId: string, text: string) => (
+ {text}
+)
-// 管理员菜单配置
const adminMenuItems: MenuProps['items'] = [
{
key: '/dashboard',
icon:
| Select | : null} {columns.map((column, index) => ({column.title} | ))} @@ -89,6 +135,23 @@ vi.mock('antd', async () => { key={resolveRowKey(record, rowKey, rowIndex)} data-testid={`table-row-${resolveRowKey(record, rowKey, rowIndex)}`} > + {rowSelection ? ( ++ { + const rawKey = resolveRowKeyValue(record, rowKey, rowIndex) + const selectedKeys = rowSelection.selectedRowKeys ?? [] + const nextKeys = selectedKeys.map(String).includes(String(rawKey)) + ? selectedKeys.filter((value) => String(value) !== String(rawKey)) + : [...selectedKeys, rawKey] + rowSelection.onChange?.(nextKeys) + }} + /> + | + ) : null} {columns.map((column, columnIndex) => { const value = column.dataIndex ? record[column.dataIndex] : undefined const content = column.render ? column.render(value, record, rowIndex) : value @@ -115,6 +178,8 @@ vi.mock('@/services/users', () => ({ listUsers: (params: UserListParams) => listUsersMock(params), deleteUser: (id: number) => deleteUserMock(id), updateUserStatus: (id: number, payload: { status: UserStatus }) => updateUserStatusMock(id, payload), + batchUpdateStatus: (ids: number[], status: UserStatus) => batchUpdateStatusMock(ids, status), + batchDelete: (ids: number[]) => batchDeleteMock(ids), getUserRoles: (id: number) => getUserRolesMock(id), })) @@ -304,6 +369,8 @@ describe('UsersPage', () => { listUsersMock.mockReset() deleteUserMock.mockReset() updateUserStatusMock.mockReset() + batchUpdateStatusMock.mockReset() + batchDeleteMock.mockReset() getUserRolesMock.mockReset() listRolesMock.mockReset() @@ -339,6 +406,16 @@ describe('UsersPage', () => { )) }) + batchUpdateStatusMock.mockImplementation(async (ids: number[], status: UserStatus) => { + currentUsers = currentUsers.map((user) => ( + ids.includes(user.id) ? { ...user, status } : user + )) + }) + + batchDeleteMock.mockImplementation(async (ids: number[]) => { + currentUsers = currentUsers.filter((user) => !ids.includes(user.id)) + }) + getUserRolesMock.mockImplementation(async (id: number) => ( id === 5 ? [roles[0], roles[1]] : [roles[1]] )) @@ -355,6 +432,7 @@ describe('UsersPage', () => { )) vi.spyOn(message, 'success').mockImplementation(() => undefined as never) vi.spyOn(message, 'error').mockImplementation(() => undefined as never) + vi.spyOn(message, 'warning').mockImplementation(() => undefined as never) }) afterEach(() => { @@ -501,4 +579,30 @@ describe('UsersPage', () => { await waitFor(() => expect(screen.getByText('admin-root')).toBeInTheDocument()) expect(listUsersMock).toHaveBeenCalledTimes(2) }) + + it('opens a stronger batch-delete confirmation and only deletes after explicit modal confirmation', async () => { + const user = userEvent.setup() + + render(
|---|