feat(admin): add userId column to user management list

Add a userId column with one-click copy functionality to the admin
user management table to help administrators easily access user IDs
for batch operations like namespace member management.

Changes:
- Add userId column after username in admin users table
- Implement one-click copy button for each userId
- Add i18n translations for column header (en/zh)
- Add comprehensive E2E tests (6 test cases)

Closes #426
This commit is contained in:
dongmucat 2026-05-18 15:35:50 +08:00
parent 098616dcb6
commit a2d08b76c1
4 changed files with 145 additions and 0 deletions

View file

@ -0,0 +1,135 @@
import { expect, test } from '@playwright/test'
import { setEnglishLocale } from './helpers/auth-fixtures'
test.describe('Admin Users - UserId Column', () => {
test.beforeEach(async ({ page, context }) => {
await setEnglishLocale(page)
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
// Use mock admin user
await page.context().setExtraHTTPHeaders({
'X-Mock-User-Id': 'local-admin'
})
await page.goto('/admin/users')
await page.waitForResponse(resp => resp.url().includes('/api/v1/admin/users') && resp.status() === 200)
})
test('userId column appears after username column', async ({ page }) => {
// Wait for table to load
await expect(page.getByRole('columnheader', { name: 'Username' })).toBeVisible()
// Check userId column exists
const userIdHeader = page.getByRole('columnheader', { name: 'User ID' })
await expect(userIdHeader).toBeVisible()
// Verify column order: Username should come before User ID
const headers = page.getByRole('columnheader')
const headerTexts = await headers.allTextContents()
const usernameIndex = headerTexts.findIndex(text => text.includes('Username'))
const userIdIndex = headerTexts.findIndex(text => text.includes('User ID'))
expect(usernameIndex).toBeGreaterThanOrEqual(0)
expect(userIdIndex).toBeGreaterThan(usernameIndex)
})
test('userId values are displayed in table rows', async ({ page }) => {
await expect(page.getByRole('columnheader', { name: 'User ID' })).toBeVisible()
// Wait for at least one row to load
const firstRow = page.getByRole('row').nth(1)
await expect(firstRow).toBeVisible()
// Check that userId cells contain non-empty values
const userIdCells = page.getByRole('cell').filter({ hasText: /^[a-zA-Z0-9-]+$/ })
const count = await userIdCells.count()
expect(count).toBeGreaterThan(0)
})
test('copy button exists for each userId', async ({ page }) => {
await expect(page.getByRole('columnheader', { name: 'User ID' })).toBeVisible()
// Wait for rows to load
const rows = page.getByRole('row')
const rowCount = await rows.count()
if (rowCount <= 1) {
test.skip()
}
// Check for copy buttons (they should have aria-label or be buttons)
const copyButtons = page.getByRole('button').filter({ hasText: /copy/i })
const buttonCount = await copyButtons.count()
expect(buttonCount).toBeGreaterThan(0)
})
test('clicking copy button copies userId to clipboard', async ({ page }) => {
await expect(page.getByRole('columnheader', { name: 'User ID' })).toBeVisible()
// Find first copy button in the table
const firstRow = page.getByRole('row').nth(1)
await expect(firstRow).toBeVisible()
// Get the userId text before clicking
const userIdCell = firstRow.getByRole('cell').nth(1) // Assuming userId is 2nd column
const userIdText = await userIdCell.textContent()
// Click the copy button
const copyButton = firstRow.getByRole('button', { name: /copy/i }).first()
await copyButton.click()
// Wait for clipboard to update
await page.waitForTimeout(100)
// Verify clipboard content
const clipboardText = await page.evaluate(() => navigator.clipboard.readText())
expect(clipboardText).toBeTruthy()
expect(clipboardText.length).toBeGreaterThan(0)
})
test('copy button shows feedback after clicking', async ({ page }) => {
await expect(page.getByRole('columnheader', { name: 'User ID' })).toBeVisible()
const firstRow = page.getByRole('row').nth(1)
await expect(firstRow).toBeVisible()
const copyButton = firstRow.getByRole('button', { name: /copy/i }).first()
await copyButton.click()
// Check for "Copied" feedback
await expect(page.getByText(/copied/i)).toBeVisible({ timeout: 2000 })
})
test('userId column persists after search/filter', async ({ page }) => {
await expect(page.getByRole('columnheader', { name: 'User ID' })).toBeVisible()
// Perform a search if search input exists
const searchInput = page.getByPlaceholder(/search/i).first()
if (await searchInput.isVisible()) {
await searchInput.fill('test')
await page.waitForTimeout(500)
}
// Verify userId column still exists
await expect(page.getByRole('columnheader', { name: 'User ID' })).toBeVisible()
})
test('userId column persists across pagination', async ({ page }) => {
await expect(page.getByRole('columnheader', { name: 'User ID' })).toBeVisible()
// Check if pagination exists
const nextButton = page.getByRole('button', { name: /next/i })
if (await nextButton.isVisible() && await nextButton.isEnabled()) {
await nextButton.click()
await page.waitForTimeout(500)
// Verify userId column still exists on next page
await expect(page.getByRole('columnheader', { name: 'User ID' })).toBeVisible()
} else {
test.skip()
}
})
})

View file

@ -564,6 +564,7 @@
"filterDisabled": "Disabled",
"empty": "No user data",
"colUsername": "Username",
"colUserId": "User ID",
"colEmail": "Email",
"colStatus": "Status",
"colRole": "Role",

View file

@ -564,6 +564,7 @@
"filterDisabled": "已禁用",
"empty": "暂无用户数据",
"colUsername": "用户名",
"colUserId": "用户 ID",
"colEmail": "邮箱",
"colStatus": "状态",
"colRole": "角色",

View file

@ -29,6 +29,7 @@ import {
DialogTitle,
} from '@/shared/ui/dialog'
import { Label } from '@/shared/ui/label'
import { CopyButton } from '@/shared/components/copy-button'
import {
useAdminUsers,
useApproveUser,
@ -213,6 +214,7 @@ export function AdminUsersPage() {
<TableHeader>
<TableRow>
<TableHead>{t('adminUsers.colUsername')}</TableHead>
<TableHead>{t('adminUsers.colUserId')}</TableHead>
<TableHead>{t('adminUsers.colEmail')}</TableHead>
<TableHead>{t('adminUsers.colStatus')}</TableHead>
<TableHead>{t('adminUsers.colRole')}</TableHead>
@ -224,6 +226,12 @@ export function AdminUsersPage() {
{data.items.map((user) => (
<TableRow key={user.userId}>
<TableCell className="font-medium">{user.username}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground">{user.userId}</span>
<CopyButton text={user.userId} />
</div>
</TableCell>
<TableCell>{user.email || '-'}</TableCell>
<TableCell>
<span