Files
Charon/docs/plans/archive/phase-6-user-management-ui.md
2026-01-26 19:22:05 +00:00

22 KiB
Raw Blame History

Phase 6: User Management UI Implementation

Status: Planning Complete Created: 2026-01-24 Estimated Effort: L (Large) - Initially estimated 40-60 hours, revised to 16-22 hours Priority: P2 - Feature Completeness Tests Targeted: 19 skipped tests in tests/settings/user-management.spec.ts Dependencies: Phase 5 (TestDataManager Auth Fix) - Infrastructure complete, blocked by environment config


Executive Summary

Goals

Complete the User Management frontend to enable 19 currently-skipped Playwright E2E tests. This phase implements missing UI components including status badges with proper color classes, role badges, resend invite action, email validation, enhanced modal accessibility, and fixes a React anti-pattern bug.

Key Finding

Most UI components already exist. After thorough analysis, the work is primarily:

  1. Verifying existing functionality (toast test IDs already exist)
  2. Implementing resend invite action (backend endpoint missing - needs implementation)
  3. Adding email format validation with visible error
  4. Fixing React anti-pattern in PermissionsModal
  5. Verification and unskipping tests

Revised Effort: 16-22 hours (pending backend resend endpoint scope).

Solution: Add missing test selectors, implement resend invite, add email validation UI, fix React bugs, and systematically unskip tests as they pass.

Test Count Reconciliation

The original plan stated 22 tests, but verification shows 19 skipped test declarations. The discrepancy came from counting 4 conditional test.skip() calls inside test bodies (not actual test declarations). See Section 2 for the complete inventory.


1. Current State Analysis

What EXISTS (in UsersPage.tsx)

The Users page at frontend/src/pages/UsersPage.tsx already contains substantial functionality:

Component Status Notes
User list table Complete Columns: User, Role, Status, Permissions, Enabled, Actions
InviteModal Complete Email, role, permission mode, host selection, URL preview
PermissionsModal Complete Edit user permissions, host toggle
Role badges Complete Purple for admin, blue for user, rounded styling
Status indicators Complete Active (green), Pending (yellow), Expired (red) with icons
Enable/Disable toggle Complete Switch component per user
Delete button Complete Trash2 icon with confirmation
Settings/Permissions button Complete For non-admin users
React Query mutations Complete All CRUD operations
Copy invite link Complete With clipboard API
URL preview for invites Complete Shows invite URL before sending

What is PARTIALLY IMPLEMENTED

Item Issue Fix Required
Status badges Class names may not match test expectations Add explicit color classes
Modal keyboard nav Escape key handling may be missing Add keyboard event handler
PermissionsModal state init React anti-pattern: useState used like useEffect Fix to use useEffect (see Section 3.6)

What is MISSING

Item Description Effort
Email validation UI Client-side format validation with visible error 2 hours
Resend invite action Button + API for pending users 6-10 hours (backend missing)
Backend resend endpoint POST /api/v1/users/{id}/resend-invite See Phase 6.4

2. Test Analysis

Summary: 19 Skipped Tests

File: tests/settings/user-management.spec.ts

# Test Name Line Category Skip Reason Status
1 should show user status badges 70 User List Status badges styling Verify
2 should display role badges 110 User List Role badges selectors Verify
3 should show pending invite status 164 User List Complex timing ⚠️ Complex
4 should open invite user modal 217 Invite Outdated skip comment Verify
5 should validate email format 283 Invite No client validation 🔧 Implement
6 should copy invite link 442 Invite Toast verification Verify
7 should open permissions modal 494 Permissions Settings icon 🔒 Auth blocked
8 should update permission mode 538 Permissions Base URL auth 🔒 Auth blocked
9 should add permitted hosts 612 Permissions Settings icon 🔒 Auth blocked
10 should remove permitted hosts 669 Permissions Settings icon 🔒 Auth blocked
11 should save permission changes 725 Permissions Settings icon 🔒 Auth blocked
12 should enable/disable user 781 Actions TestDataManager 🔒 Auth blocked
13 should change user role 828 Actions Not implemented Future
14 should delete user with confirmation 848 Actions Delete button 🔒 Auth blocked
15 should resend invite for pending user 956 Actions Not implemented 🔧 Implement
16 should be keyboard navigable 1014 A11y Known flaky ⚠️ Flaky
17 should require admin role for access 1091 Security Routing design By design
18 should show error for regular user access 1125 Security Routing design By design
19 should have proper ARIA labels 1157 A11y ARIA incomplete Verify

Legend

  • Verify: Likely already works, just needs verification
  • 🔧 Fix/Implement: Requires small code change
  • 🔒 Auth blocked: Blocked by Phase 5 (TestDataManager)
  • ⚠️ Complex/Flaky: Timing or complexity issues
  • By design: Intentional skip (routing behavior)
  • Future: Feature not prioritized

Tests Addressable in Phase 6

Without Auth Fix (can implement now): 6 tests

  • Test 1: Status badges styling (verify only)
  • Test 2: Role badges (verify only)
  • Test 4: Open invite modal (verify only - button IS implemented)
  • Test 5: Email validation
  • Test 6: Copy invite link (verify only - toast test IDs already exist)
  • Test 19: ARIA labels (verify only)

With Resend Invite: 1 test

  • Test 15: Resend invite

After Phase 5 Auth Fix: 6 tests

  • Tests 7-12, 14: Permission/Action tests

Detailed Test Requirements

Test 1: should show user status badges (Line 70)

Test code:

const statusCell = page.locator('td').filter({
  has: page.locator('span').filter({
    hasText: /active|pending.*invite|invite.*expired/i,
  }),
});

const activeStatus = page.locator('span').filter({ hasText: /^active$/i });

// Expects class to include 'green', 'text-green-400', or 'success'
const hasGreenColor = await activeStatus.first().evaluate((el) => {
  return el.className.includes('green') ||
         el.className.includes('text-green-400') ||
         el.className.includes('success');
});

Current code (UsersPage.tsx line ~459):

<span className="inline-flex items-center gap-1 text-green-400 text-xs">
  <Check className="h-3 w-3" />
  {t('common.active')}
</span>

Analysis: Current code already includes text-green-400 class.

Action: Verify only - unskip and run test.

Test 2: should display role badges (Line 110)

Test code:

const adminBadge = page.locator('span').filter({ hasText: /^admin$/i });

// Expects 'purple', 'blue', or 'rounded' in class
const hasDistinctColor = await adminBadge.evaluate((el) => {
  return el.className.includes('purple') ||
         el.className.includes('blue') ||
         el.className.includes('rounded');
});

Current code (UsersPage.tsx line ~445):

<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
  user.role === 'admin' ? 'bg-purple-900/30 text-purple-400' : 'bg-blue-900/30 text-blue-400'
}`}>
  {user.role}
</span>

Analysis: Classes include rounded and purple/blue.

Action: Verify only - unskip and run test.

Test 4: should open invite user modal (Line 217)

Test code:

const inviteButton = page.getByRole('button', { name: /invite.*user/i });
await expect(inviteButton).toBeVisible();
await inviteButton.click();
// Verify modal is visible
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();

Current state: Invite button IS implemented in UsersPage.tsx. The skip comment is outdated.

Action: Verify only - unskip and run test.

Test 5: should validate email format (Line 283)

Test code:

const sendButton = page.getByRole('button', { name: /send.*invite/i });
const isDisabled = await sendButton.isDisabled();
// OR error message shown
const errorMessage = page.getByText(/invalid.*email|email.*invalid|valid.*email/i);

Current code: Button disabled when !email, but no format validation visible.

Action: 🔧 Implement - Add email regex validation with error display.

Test code:

const copiedToast = page.locator('[data-testid="toast-success"]').filter({
  hasText: /copied|clipboard/i,
});

Current state: Toast component already has data-testid={toast-${toast.type}} at Toast.tsx:31.

Action: Verify only - unskip and run test. No code changes needed.

Test 15: should resend invite for pending user (Line 956)

Test code:

const resendButton = page.getByRole('button', { name: /resend/i });
await resendButton.first().click();
await waitForToast(page, /sent|resend/i, { type: 'success' });

Current state: Resend action not implemented.

Action: 🔧 Implement - Add resend button for pending users + API call.

Test 19: should have proper ARIA labels (Line 1157)

Test code:

const inviteButton = page.getByRole('button', { name: /invite.*user/i });
// Checks for accessible name on action buttons
const ariaLabel = await button.getAttribute('aria-label');
const title = await button.getAttribute('title');
const text = await button.textContent();

Current state:

  • Invite button: text content "Invite User"
  • Delete button: aria-label={t('users.deleteUser')}
  • Settings button: aria-label={t('users.editPermissions')}

Action: Verify only - unskip and run test.


3. Implementation Phases

Phase 6.1: Verify Existing Functionality (3 hours)

Goal: Confirm tests 1, 2, 4, 6, 19 pass without code changes.

Tests in Batch:

  • Test 1: should show user status badges
  • Test 2: should display role badges
  • Test 4: should open invite user modal
  • Test 6: should copy invite link (toast test IDs already exist)
  • Test 19: should have proper ARIA labels

Tasks:

  1. Temporarily remove test.skip from tests 1, 2, 4, 6, 19
  2. Run tests individually
  3. Document results
  4. Permanently unskip passing tests

Commands:

# Test status badges
npx playwright test tests/settings/user-management.spec.ts \
  --grep "should show user status badges" --project=chromium

# Test role badges
npx playwright test tests/settings/user-management.spec.ts \
  --grep "should display role badges" --project=chromium

# Test invite modal opens
npx playwright test tests/settings/user-management.spec.ts \
  --grep "should open invite user modal" --project=chromium

# Test copy invite link (toast test IDs already exist)
npx playwright test tests/settings/user-management.spec.ts \
  --grep "should copy invite link" --project=chromium

# Test ARIA labels
npx playwright test tests/settings/user-management.spec.ts \
  --grep "should have proper ARIA labels" --project=chromium

Expected outcome: 4-5 tests pass immediately.


Phase 6.2: Email Validation UI (2 hours)

Goal: Add client-side email format validation with visible error.

File to modify: frontend/src/pages/UsersPage.tsx (InviteModal)

Implementation:

// Add state in InviteModal component
const [emailError, setEmailError] = useState<string | null>(null)

// Email validation function
const validateEmail = (email: string): boolean => {
  if (!email) {
    setEmailError(null)
    return false
  }
  const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
  if (!emailRegex.test(email)) {
    setEmailError(t('users.invalidEmail'))
    return false
  }
  setEmailError(null)
  return true
}

// Update email input (replace existing Input)
<div>
  <Input
    label={t('users.emailAddress')}
    type="email"
    value={email}
    onChange={(e) => {
      setEmail(e.target.value)
      validateEmail(e.target.value)
    }}
    placeholder="user@example.com"
    aria-invalid={!!emailError}
    aria-describedby={emailError ? 'email-error' : undefined}
  />
  {emailError && (
    <p
      id="email-error"
      className="mt-1 text-sm text-red-400"
      role="alert"
    >
      {emailError}
    </p>
  )}
</div>

// Update button disabled logic
disabled={!email || !!emailError}

Translation key to add (to appropriate i18n file):

{
  "users.invalidEmail": "Please enter a valid email address"
}

Validation:

npx playwright test tests/settings/user-management.spec.ts \
  --grep "should validate email format" --project=chromium

Expected outcome: Test 5 passes.


Phase 6.3: Resend Invite Action (6-10 hours)

Goal: Add resend invite button for pending users.

Backend Verification

REQUIRED: Check if backend endpoint exists before proceeding:

grep -r "resend" backend/internal/api/handlers/
grep -r "ResendInvite" backend/internal/api/

Result of verification: Backend endpoint does not exist. Both grep commands return no results.

Contingency: If backend is missing (confirmed), effort increases to 8-10 hours to implement:

  • Endpoint: POST /api/v1/users/{id}/resend-invite
  • Handler: Regenerate token, send email, return new token info
  • Tests: Unit tests for the new handler

Frontend Implementation

File: frontend/src/api/users.ts

Add API function:

/**
 * Resends an invitation email to a pending user.
 * @param id - The user ID to resend invite to
 * @returns Promise resolving to InviteUserResponse with new token
 */
export const resendInvite = async (id: number): Promise<InviteUserResponse> => {
  const response = await client.post<InviteUserResponse>(`/users/${id}/resend-invite`)
  return response.data
}

File: frontend/src/pages/UsersPage.tsx

Add mutation:

const resendInviteMutation = useMutation({
  mutationFn: resendInvite,
  onSuccess: (data) => {
    queryClient.invalidateQueries({ queryKey: ['users'] })
    if (data.email_sent) {
      toast.success(t('users.inviteResent'))
    } else {
      toast.success(t('users.inviteCreatedNoEmail'))
    }
  },
  onError: (error: unknown) => {
    const err = error as { response?: { data?: { error?: string } } }
    toast.error(err.response?.data?.error || t('users.resendFailed'))
  },
})

Add button in user row actions:

{user.invite_status === 'pending' && (
  <button
    onClick={() => resendInviteMutation.mutate(user.id)}
    className="p-1.5 text-gray-400 hover:text-blue-400 hover:bg-gray-800 rounded"
    title={t('users.resendInvite')}
    aria-label={t('users.resendInvite')}
    disabled={resendInviteMutation.isPending}
  >
    <Mail className="h-4 w-4" />
  </button>
)}

Translation keys:

{
  "users.resendInvite": "Resend Invite",
  "users.inviteResent": "Invitation resent successfully",
  "users.inviteCreatedNoEmail": "New invite created. Email could not be sent.",
  "users.resendFailed": "Failed to resend invitation"
}

Validation:

npx playwright test tests/settings/user-management.spec.ts \
  --grep "should resend invite" --project=chromium

Expected outcome: Test 15 passes.


Phase 6.4: Modal Keyboard Navigation (2 hours)

Goal: Ensure Escape key closes modals.

File to modify: frontend/src/pages/UsersPage.tsx

Implementation (add to InviteModal and PermissionsModal):

// Add useEffect for keyboard handler
useEffect(() => {
  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Escape') {
      handleClose()
    }
  }

  if (isOpen) {
    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }
}, [isOpen, handleClose])

Note: Test 16 (keyboard navigation) is marked as known flaky and may remain skipped.


Phase 6.5: PermissionsModal useState Bug Fix (1 hour)

Goal: Fix React anti-pattern in PermissionsModal.

File to modify: frontend/src/pages/UsersPage.tsx (line 339)

Bug: useState is being used like a useEffect, which is a React anti-pattern:

// WRONG - useState used like an effect (current code at line 339)
useState(() => {
  if (user) {
    setPermissionMode(user.permission_mode || 'allow_all')
    setSelectedHosts(user.permitted_hosts || [])
  }
})

Fix: Replace with proper useEffect with dependency:

// CORRECT - useEffect with dependency
useEffect(() => {
  if (user) {
    setPermissionMode(user.permission_mode || 'allow_all')
    setSelectedHosts(user.permitted_hosts || [])
  }
}, [user])

Why this matters: The useState initializer only runs once on mount. The current code appears to work incidentally but:

  1. Will not update state when user prop changes
  2. May cause stale data bugs
  3. Violates React's data flow principles

Validation:

# Run TypeScript check
cd frontend && npm run typecheck

# Run related permission tests (after Phase 5 auth fix)
npx playwright test tests/settings/user-management.spec.ts \
  --grep "permissions" --project=chromium

4. Implementation Order

Week 1 (10-14 hours)
├── Phase 6.1: Verify Existing (3h) → Tests 1, 2, 4, 6, 19
├── Phase 6.2: Email Validation (2h) → Test 5
├── Phase 6.3: Resend Invite (6-10h) → Test 15
│   └── Includes backend endpoint implementation
└── Phase 6.5: PermissionsModal Bug Fix (1h) → Stability

Week 2 (2-3 hours)
└── Phase 6.4: Modal Keyboard Nav (2h) → Partial for Test 16

Validation & Cleanup (3 hours)
└── Run full suite, update skip comments

5. Files to Modify

Priority 1: Required for Test Enablement

File Changes
frontend/src/pages/UsersPage.tsx Email validation, resend invite button, keyboard nav, PermissionsModal useState→useEffect fix
frontend/src/api/users.ts Add resendInvite function
tests/settings/user-management.spec.ts Unskip verified tests

Priority 2: Backend (REQUIRED - endpoint missing)

File Changes
backend/internal/api/handlers/user_handler.go Add resend-invite endpoint
backend/internal/api/routes.go Register new route
backend/internal/api/handlers/user_handler_test.go Add tests for resend endpoint

Priority 3: Translations

File Keys to Add
frontend/src/i18n/locales/en.json invalidEmail, resendInvite, inviteResent, etc.

NOT Required (Already Implemented)

File Status
frontend/src/components/Toast.tsx Already has data-testid={toast-${toast.type}}

6. Validation Strategy

After Each Phase

# Run specific tests
npx playwright test tests/settings/user-management.spec.ts \
  --grep "<test name>" --project=chromium

Final Validation

# Run all user management tests
npx playwright test tests/settings/user-management.spec.ts --project=chromium

# Expected:
# - ~12-14 tests passing (up from ~5)
# - ~6-8 tests still skipped (auth blocked or by design)

Test Coverage

cd frontend && npm run test:coverage
# Verify UsersPage.tsx >= 85%

7. Expected Outcomes

Tests to Unskip After Phase 6

Test Expected Outcome
should show user status badges Pass
should display role badges Pass
should open invite user modal Pass
should validate email format Pass
should copy invite link Pass
should resend invite for pending user Pass
should have proper ARIA labels Pass

Total: 7 tests enabled

Tests Remaining Skipped

Test Reason
Tests 7-12, 14 (7 tests) 🔒 TestDataManager auth (Phase 5)
Test 3: pending invite status ⚠️ Complex timing
Test 13: change user role Feature not implemented
Test 16: keyboard navigation ⚠️ Known flaky
Tests 17-18: admin access Routing design (intentional)

Total remaining skipped: ~12 tests (down from 22)


8. Risk Assessment

Risk Likelihood Impact Mitigation
Backend resend endpoint missing CONFIRMED High Backend implementation included in Phase 6.3 (6-10h)
Tests pass locally, fail in CI Medium Medium Run in both environments
Translation keys missing Low Low Add to all locale files
PermissionsModal bug causes regressions Low Medium Fix early in Phase 6.5 with testing

9. Success Metrics

Metric Before Phase 6 After Phase 6 Target
User management tests passing ~5 ~12-14 15+
User management tests skipped 19 10-12 <10
Frontend coverage (UsersPage) TBD ≥85% 85%

10. Timeline

Phase Effort Cumulative
6.1: Verify Existing (5 tests) 3h 3h
6.2: Email Validation 2h 5h
6.3: Resend Invite (backend included) 6-10h 11-15h
6.4: Modal Keyboard Nav 2h 13-17h
6.5: PermissionsModal Bug Fix 1h 14-18h
Validation & Cleanup 3h 17-21h
Buffer 3h 16-22h

Total: 16-22 hours (range depends on backend complexity)


Change Log

Date Author Change
2026-01-24 Planning Agent Initial plan created with detailed test analysis
2026-01-24 Planning Agent REVISION: Applied Supervisor corrections:
- Toast test IDs already exist (Phase 6.2 removed)
- Updated test line numbers to actual values (70, 110, 217, 283, 442, 956, 1014, 1157)
- Added Test 4 and Test 6 to Phase 6.1 verification batch (5 tests total)
- Added Phase 6.5: PermissionsModal useState bug fix
- Backend resend endpoint confirmed missing (grep verification)
- Corrected test count: 19 skipped tests (not 22)
- Updated effort estimates: 16-22h (was 17h)