Skip to content

VIBE-362: Add user management feature for system administrators #310#335

Open
alexbottenberg wants to merge 48 commits intomasterfrom
feature/310-user-management-implementation
Open

VIBE-362: Add user management feature for system administrators #310#335
alexbottenberg wants to merge 48 commits intomasterfrom
feature/310-user-management-implementation

Conversation

@alexbottenberg
Copy link
Copy Markdown
Contributor

@alexbottenberg alexbottenberg commented Feb 3, 2026

https://tools.hmcts.net/jira/browse/VIBE-362

Implements comprehensive user search, management, and deletion functionality for system admin users. Includes advanced filtering with removable filter tags using GOV.UK Design System components.

Features:

  • User search with filters (email, user ID, provenance ID, roles, provenances)
  • Removable filter tags with MOJ pattern styling
  • Server-side pagination (25 users per page)
  • User management page showing full user details
  • User deletion with confirmation workflow
  • Self-deletion prevention
  • Bilingual support (English/Welsh)
  • Database indexes for improved search performance

Technical details:

  • Dynamic routes using [userId] parameter pattern
  • Session-based filter persistence
  • GOV.UK Tag component for filter display
  • Enhanced error logging with structured context
  • All 342 tests passing

Resolves #310

Security Vulnerability Assessment

CVE Suppression: Are there any CVEs present in the codebase (either newly introduced or pre-existing) that are being intentionally suppressed or ignored by this commit?

  • Yes
  • No

Checklist

  • commit messages are meaningful and follow good commit message guidelines
  • README and other documentation has been updated / added (if needed)
  • tests have been updated / new tests has been added (if needed)
  • Does this PR introduce a breaking change

Summary by CodeRabbit

  • New Features

    • System Admin: find/filter/paginate users, view/manage user details, confirm-delete and success screens — bilingual (en/cy). Clear/remove filters and session-backed filter persistence.
    • Verified users: add/manage list-type email subscriptions (multi-step flow, language selection, review/confirm).
  • Performance

    • Added database indexes for email, role and user provenance; seed data for pagination testing.
  • UI / Styling

    • New user-management styles; dashboard tile renamed to "Find, update and delete users".
  • Tests

    • Extensive unit and end-to-end suites (including accessibility checks).
  • Documentation

    • Detailed plans, reviews and task checklists added.
  • Chores

    • Dependency resolution updates.

Implements comprehensive user search, management, and deletion functionality
for system admin users. Includes advanced filtering with removable filter
tags using GOV.UK Design System components.

Features:
- User search with filters (email, user ID, provenance ID, roles, provenances)
- Removable filter tags with MOJ pattern styling
- Server-side pagination (25 users per page)
- User management page showing full user details
- User deletion with confirmation workflow
- Self-deletion prevention
- Bilingual support (English/Welsh)
- Database indexes for improved search performance

Technical details:
- Dynamic routes using [userId] parameter pattern
- Session-based filter persistence
- GOV.UK Tag component for filter display
- Enhanced error logging with structured context
- All 342 tests passing

Resolves #310

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 3, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a bilingual System Admin User Management feature (find/manage/delete users) with server-rendered handlers, Prisma-backed search/pagination and transactional deletion, validators, session-backed filters, templates/styles, unit and E2E tests, DB migrations/indexes, and supporting subscription-list-type functionality and docs.

Changes

Cohort / File(s) Summary
Database schema & migrations
apps/postgres/prisma/schema.prisma, apps/postgres/prisma/migrations/..._add_user_search_indexes/migration.sql, apps/postgres/prisma/migrations/..._add_subscription_list_type/migration.sql
Adds Prisma indexes on User (email, role, userProvenance) and introduces subscription_list_type table plus its indexes and constraints. Review migration order and schema mapping.
Seed & schema discovery
apps/postgres/prisma/seed.ts, apps/postgres/src/schema-discovery.ts, apps/postgres/src/schema-discovery.test.ts
Adds 50 deterministic test users for pagination and includes subscription-list-types schema in discovery; tests updated.
User management data layer & tests
libs/system-admin-pages/src/user-management/queries.ts, .../queries.test.ts
Implements searchUsers, getUserById, deleteUserById with pagination and transactional deletion; tests mock Prisma transactions—verify select shapes and deletion ordering.
Validation & tests
libs/system-admin-pages/src/user-management/validation.ts, .../validation.test.ts
Adds field validators and aggregators (email, userId, provenance, roles) and delete confirmation validator with comprehensive unit tests.
Find users flow & helpers
libs/system-admin-pages/src/pages/find-users/index.ts, .../index.njk, .../en.ts, .../cy.ts, .../index.test.ts, .../clear-filters/index.ts, .../remove-filter/index.ts
GET/POST handlers with session-backed filters, pagination, selected-filter UI, endpoints to clear/remove filters, bilingual locales and extensive tests—check session shape and pagination links.
Manage user page & tests
libs/system-admin-pages/src/pages/manage-user/[userId]/index.ts, .../index.njk, .../en.ts, .../cy.ts, .../index.test.ts
GET handler renders localized user details, date formatting, warning, delete link; handles 404/500 and tests.
Delete confirmation & success pages
libs/system-admin-pages/src/pages/delete-user-confirm/..., libs/system-admin-pages/src/pages/delete-user-success/...
Adds GET/POST confirmation with validation and self-deletion guard (403), invokes deletion, success page and tests—inspect self-deletion checks and error flows.
UI templates, locales & styles
libs/system-admin-pages/src/pages/*/*.njk, libs/system-admin-pages/src/pages/*/*.{en,cy}.ts, libs/system-admin-pages/src/assets/css/user-management.scss, apps/web/src/assets/css/index.scss, libs/system-admin-pages/package.json
Adds Nunjucks templates, English/Welsh locales, SCSS and package export for user-management styling.
System Admin dashboard updates & tests
libs/system-admin-pages/src/pages/system-admin-dashboard/*, libs/system-admin-pages/src/pages/system-admin-dashboard/index.njk.test.ts, e2e-tests/tests/system-admin-dashboard.spec.ts
Renames tile/route from /user-management to /find-users and updates unit/E2E tests.
E2E tests & deps
e2e-tests/tests/user-management.spec.ts, e2e-tests/tests/system-admin-dashboard.spec.ts, package.json
Adds Playwright tests covering find/manage/delete flows and accessibility; updates package resolutions. Ensure CI env variables and DB cleanup utilities are present.
Subscription list-type feature & integration
libs/subscription-list-types/**, libs/verified-pages/src/pages/subscription-*/**, tsconfig.json
New package for list-type subscriptions: Prisma model, queries, services, pages (add/select/language/confirm), templates, locales, tests, session augmentation and routing; TypeScript path alias added. Review package exports and session augmentation.
Verified pages: subscription management integration
libs/verified-pages/src/pages/subscription-management/*, .../delete-list-type-subscription/index.ts
Adds list-type subscription UI to subscription-management, delete handler and session cleanup/integration. Verify parallel fetch and session clearing.
UI/UX small updates
libs/web-core/src/assets/css/filter-panel.scss, libs/web-core/src/assets/js/filter-panel.ts
UI tweaks: filter heading styles and glyph changes from +/- to triangles. Confirm visual consistency.
Documentation & tickets
docs/tickets/310/*, docs/tickets/296/*
Adds detailed ticket, plan, review and task docs for User Management and List Type Subscription features. Review outstanding clarifications listed in docs.

Sequence Diagram(s)

sequenceDiagram
    participant Admin as System Admin
    participant Browser as Browser
    participant Server as Express App
    participant DB as Prisma / Database

    Admin->>Browser: Request Find users (GET /find-users?lng=...)
    Browser->>Server: GET /find-users?lng=...
    Server->>DB: searchUsers(filters, page)
    DB-->>Server: paginated results
    Server-->>Browser: Render find-users page

    Admin->>Browser: Open Manage user (GET /manage-user/[userId]?lng=...)
    Browser->>Server: GET /manage-user/[userId]?lng=...
    Server->>DB: getUserById(userId)
    DB-->>Server: user details
    Server-->>Browser: Render manage-user page

    Admin->>Browser: Open Delete confirm (GET /delete-user-confirm/[userId]?lng=...)
    Browser->>Server: GET /delete-user-confirm/[userId]?lng=...
    Server->>DB: getUserById(userId)
    DB-->>Server: user details
    Server-->>Browser: Render delete confirmation

    Admin->>Browser: Submit "Yes" (POST /delete-user-confirm/[userId])
    Browser->>Server: POST /delete-user-confirm/[userId]
    Server->>DB: $transaction -> delete related records -> delete user
    DB-->>Server: transaction committed
    Server-->>Browser: Redirect /delete-user-success?lng=...
    Browser->>Server: GET /delete-user-success?lng=...
    Server-->>Browser: Render success page
Loading

Possibly related issues

Possibly related PRs

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/310-user-management-implementation

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
libs/system-admin-pages/src/pages/system-admin-dashboard/cy.ts (1)

27-27: ⚠️ Potential issue | 🟡 Minor

Inconsistent href between English and Welsh locales.

The Blob Explorer tile has href: "/blob-explorer" here, but the English locale has href: "/blob-explorer-locations". This may cause navigation issues when switching languages.

Suggested fix
     {
       title: "Archwiliwr Blob",
       description: "Darganfod cynnwys wedi'i uwchlwytho i bob lleoliad",
-      href: "/blob-explorer"
+      href: "/blob-explorer-locations"
     },
🧹 Nitpick comments (14)
libs/system-admin-pages/src/pages/find-users-clear-filters/index.ts (2)

17-17: Unnecessary async modifier.

The handler contains no await statements, so async is superfluous.

Suggested fix
-const getHandler = async (req: Request, res: Response) => {
+const getHandler = (req: Request, res: Response) => {

4-15: Extract the UserManagementSession interface to a shared types file.

The interface is duplicated identically across find-users, find-users-remove-filter, and find-users-clear-filters. Moving it to a shared location within the system-admin-pages package would reduce duplication and improve maintainability.

libs/system-admin-pages/src/pages/find-users/cy.ts (1)

25-25: Consider Welsh pluralisation for resultsCount.

The English version handles singular/plural (user/users), but the Welsh version uses a single form. Welsh has different pluralisation rules—verify with a Welsh speaker whether "defnyddiwr" (singular) or "defnyddwyr" (plural) should be used conditionally.

libs/system-admin-pages/src/user-management/validation.ts (2)

1-1: Remove unused EMAIL_REGEX constant.

This regex is defined but never used. The comment on Line 24 confirms email format validation was intentionally omitted to allow partial search matching.

🧹 Proposed fix
-const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
 const ALPHANUMERIC_REGEX = /^[a-zA-Z0-9]+$/;

133-148: Minor duplication in error object.

Both branches return identical error objects. Consider extracting or simplifying.

♻️ Proposed simplification
 export function validateDeleteConfirmation(confirmation: string | undefined): ValidationError | null {
-  if (!confirmation) {
-    return {
-      text: "Select yes or no to continue",
-      href: "#confirmation"
-    };
-  }
-
-  if (confirmation !== "yes" && confirmation !== "no") {
+  if (confirmation !== "yes" && confirmation !== "no") {
     return {
       text: "Select yes or no to continue",
       href: "#confirmation"
     };
   }
 
   return null;
 }
libs/system-admin-pages/src/pages/find-users-remove-filter/index.ts (1)

4-15: Consolidate UserManagementSession interface to a shared types file.

This interface is duplicated identically across three page handlers: find-users/index.ts, find-users-remove-filter/index.ts, and find-users-clear-filters/index.ts. Create a shared types file (e.g., libs/system-admin-pages/src/pages/types.ts) and import it from all three modules to reduce duplication and ease future maintenance.

apps/postgres/prisma/schema.prisma (1)

43-45: Email index strategy does not optimise the current search pattern. Email filtering uses contains with case-insensitive mode, which translates to SQL ILIKE with leading wildcards (e.g. '%test%'). A standard B-tree index on email cannot optimise this pattern and may result in full table scans. For substring searches, consider adding a pg_trgm extension with a GIN index instead, or refactor searches to use prefix matching (startsWith) if the use case permits.

libs/system-admin-pages/src/user-management/queries.ts (1)

19-29: Consider aligning with existing User interface.

The User interface here uses string for role and userProvenance, whilst libs/account/src/repository/model.ts defines these as union types ("VERIFIED" | "LOCAL_ADMIN" | ...). Using loose string types may bypass compile-time checks.

libs/system-admin-pages/src/pages/manage-user/[userId]/index.njk (1)

11-14: Consider localising the iconFallbackText.

The iconFallbackText: "Warning" is hardcoded in English. For bilingual support, this should come from the content object (e.g., iconFallbackText).

♻️ Proposed fix
     {{ govukWarningText({
       text: warningText,
-      iconFallbackText: "Warning"
+      iconFallbackText: warningIconFallbackText
     }) }}
libs/system-admin-pages/src/assets/css/user-management.scss (2)

1-93: Consider using GOV.UK colour variables instead of hardcoded hex values.

Multiple hardcoded colours (#b1b4b6, #f3f2f1, #003078, #fd0, #0b0c0c) could use GOV.UK Design System variables (e.g., govuk-colour("mid-grey"), govuk-colour("light-grey")) for consistency and maintainability.


1-54: Excessive use of !important overrides.

The heavy reliance on !important suggests conflicts with GOV.UK default styling. Consider adjusting selector specificity or component structure rather than forcing overrides, which can complicate future maintenance.

libs/system-admin-pages/src/pages/find-users/index.test.ts (1)

11-13: Avoid any for the session mock. Line 13 drops type safety; a small interface keeps tests strict and self-documenting.

Suggested fix
+type MockSession = {
+  userManagement?: {
+    filters?: {
+      email?: string;
+      userId?: string;
+      userProvenanceId?: string;
+      roles?: string[];
+      provenances?: string[];
+    };
+    page?: number;
+  };
+};
+
-  let mockSession: any;
+  let mockSession: MockSession;

As per coding guidelines: Use TypeScript strict mode. Avoid any without justification.

libs/system-admin-pages/src/user-management/queries.test.ts (1)

25-130: Consider adding tests for userProvenanceId and provenances filters.

The searchUsers tests cover email, userId, roles, and pagination, but omit userProvenanceId exact-match and provenances array filters. Adding these would complete filter coverage.

libs/system-admin-pages/src/pages/find-users/index.ts (1)

8-19: Move interface to bottom of module.

Per coding guidelines, interfaces and types should be placed at the bottom of the module.

♻️ Suggested reordering

Move the UserManagementSession interface to the end of the file, after the exports on lines 221-222.

As per coding guidelines: "Module ordering: constants outside function scope at top, exported functions next, other functions in usage order, interfaces and types at bottom."

Comment on lines +29 to +31
**Deletion Strategy**: Soft delete approach initially, with cascade deletion of related subscriptions

**Navigation State**: Preserve search filters and pagination when returning from Manage page via session storage
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Align deletion strategy wording with the requirement. Line 29 states “Soft delete approach initially”, but the objectives specify deleted users must be removed from the database. Please update this section to match the agreed behaviour.

Comment on lines +71 to +83
### 4. Missing Database Indexes
**Location**: Database schema
**Problem**: No indexes exist on frequently queried fields (email, role, userProvenance).
**Impact**: Poor query performance with large user datasets, potential timeout on searches.
**Solution**: Add indexes in Prisma schema:
```prisma
model User {
// ...
@@index([email])
@@index([role])
@@index([userProvenance])
}
```
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update the review narrative if indexes are already added. Lines 71-83 list missing indexes as critical, but the PR objectives mention indexes have been introduced. Please reconcile this section to avoid false negatives in the review note.

Comment on lines +12 to +18
## Database Layer

- [ ] Add database indexes for user search (email, role, userProvenance)
- [x] Create `src/user-management/queries.ts` with searchUsers function
- [x] Add deleteUserById function to `src/user-management/queries.ts`
- [x] Add getUserById function to `src/user-management/queries.ts`
- [x] Add unit tests for all query functions in `src/user-management/queries.test.ts`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Mark the index task as complete.
The migration adds these indexes, so the checklist should be ticked.

Suggested fix
- - [ ] Add database indexes for user search (email, role, userProvenance)
+ - [x] Add database indexes for user search (email, role, userProvenance)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Database Layer
- [ ] Add database indexes for user search (email, role, userProvenance)
- [x] Create `src/user-management/queries.ts` with searchUsers function
- [x] Add deleteUserById function to `src/user-management/queries.ts`
- [x] Add getUserById function to `src/user-management/queries.ts`
- [x] Add unit tests for all query functions in `src/user-management/queries.test.ts`
## Database Layer
- [x] Add database indexes for user search (email, role, userProvenance)
- [x] Create `src/user-management/queries.ts` with searchUsers function
- [x] Add deleteUserById function to `src/user-management/queries.ts`
- [x] Add getUserById function to `src/user-management/queries.ts`
- [x] Add unit tests for all query functions in `src/user-management/queries.test.ts`

Comment on lines +27 to +29
* on the dashboard, when the system admin user clicks on the 'User Management' tab, then the system admin user is taken to the 'Find, update and delete a user' page and can view the list of CaTH users in a table that displays the following columns in sequential order 'Email', 'Role' and 'Provenance'. The 4th column without a title provides a link to 'Manage' each user provided in the table rows. Where there are several users, then a maximum of 25 users are displayed per page
* A filter panel is provided on the left side of the page. The first section displays the 'Selected filters'. 2nd section displays 3 free text search fields. First is the 'Email' search field. When the correct search criteria is inputted and run, then the user list is updated to display matching results; however, where the wrong email is inputted, the following error message is displayed; '{**}There is a problem'. '{**}No users could be found matching your search criteria. Try adjusting or clearing the filters.' The second and third search fields are the 'User ID' and 'User Provenance ID' fields which display the following descriptive message beneath the field name and above the search bar 'Must be an exact match'. The 3rd section provides 2 check box sub-sections titled 'Role' which has the following options, 'Verified, CTSC Admin, Local[+] Admin and System Admin', and then 'Provenance' with the following options 'CFT IdAM and SSO'. [/+][-]Admin, CTSC Super Admin, Local Super Admin and System Admin', and then 'Provenance' with the following options 'B2C, CFT IdAM, Crime IdAM and SSO'.[/-]
* Clicking on the 'Manage' link beside a user takes the system admin to the 'Manage <user email>' page which displays a caution symbol underneath the page title with the following descriptive message beside it; '{**}Ensure authorisation has been granted before updating this user'{**}. The following rows are provided in a displayed table; 'User ID, Email, Role. Provenance, Provenance ID, Creation Date and Last sign in'. This is followed by a red 'Delete user' button.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove stray diff artefacts in the acceptance criteria.
The bracketed [+]/[-] markers look like leftover diff text and break the sentence.

Suggested fix
-* A filter panel is provided on the left side of the page. The first section displays the 'Selected filters'. 2nd section displays 3 free text search fields. First is the 'Email' search field. When the correct search criteria is inputted and run, then the user list is updated to display matching results; however, where the wrong email is inputted, the following error message is displayed; '{**}There is a problem'. '{**}No users could be found matching your search criteria. Try adjusting or clearing the filters.' The second and third search fields are the 'User ID' and 'User Provenance ID' fields which display the following descriptive message beneath the field name and above the search bar 'Must be an exact match'. The 3rd section provides 2 check box sub-sections titled 'Role' which has the following options, 'Verified, CTSC Admin, Local[+] Admin and System Admin', and then 'Provenance' with the following options 'CFT IdAM and SSO'. [/+][-]Admin, CTSC Super Admin, Local Super Admin and System Admin', and then 'Provenance' with the following options 'B2C, CFT IdAM, Crime IdAM and SSO'.[/-]
+* A filter panel is provided on the left side of the page. The first section displays the 'Selected filters'. 2nd section displays 3 free text search fields. First is the 'Email' search field. When the correct search criteria is inputted and run, then the user list is updated to display matching results; however, where the wrong email is inputted, the following error message is displayed; '{**}There is a problem'. '{**}No users could be found matching your search criteria. Try adjusting or clearing the filters.' The second and third search fields are the 'User ID' and 'User Provenance ID' fields which display the following descriptive message beneath the field name and above the search bar 'Must be an exact match'. The 3rd section provides 2 check box sub-sections titled 'Role' which has the following options, 'Verified, CTSC Admin, Local Admin and System Admin', and then 'Provenance' with the following options 'B2C, CFT IdAM, Crime IdAM and SSO'.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* on the dashboard, when the system admin user clicks on the 'User Management' tab, then the system admin user is taken to the 'Find, update and delete a user' page and can view the list of CaTH users in a table that displays the following columns in sequential order 'Email', 'Role' and 'Provenance'. The 4th column without a title provides a link to 'Manage' each user provided in the table rows. Where there are several users, then a maximum of 25 users are displayed per page
* A filter panel is provided on the left side of the page. The first section displays the 'Selected filters'. 2nd section displays 3 free text search fields. First is the 'Email' search field. When the correct search criteria is inputted and run, then the user list is updated to display matching results; however, where the wrong email is inputted, the following error message is displayed; '{**}There is a problem'. '{**}No users could be found matching your search criteria. Try adjusting or clearing the filters.' The second and third search fields are the 'User ID' and 'User Provenance ID' fields which display the following descriptive message beneath the field name and above the search bar 'Must be an exact match'. The 3rd section provides 2 check box sub-sections titled 'Role' which has the following options, 'Verified, CTSC Admin, Local[+] Admin and System Admin', and then 'Provenance' with the following options 'CFT IdAM and SSO'. [/+][-]Admin, CTSC Super Admin, Local Super Admin and System Admin', and then 'Provenance' with the following options 'B2C, CFT IdAM, Crime IdAM and SSO'.[/-]
* Clicking on the 'Manage' link beside a user takes the system admin to the 'Manage <user email>' page which displays a caution symbol underneath the page title with the following descriptive message beside it; '{**}Ensure authorisation has been granted before updating this user'{**}. The following rows are provided in a displayed table; 'User ID, Email, Role. Provenance, Provenance ID, Creation Date and Last sign in'. This is followed by a red 'Delete user' button.
* on the dashboard, when the system admin user clicks on the 'User Management' tab, then the system admin user is taken to the 'Find, update and delete a user' page and can view the list of CaTH users in a table that displays the following columns in sequential order 'Email', 'Role' and 'Provenance'. The 4th column without a title provides a link to 'Manage' each user provided in the table rows. Where there are several users, then a maximum of 25 users are displayed per page
* A filter panel is provided on the left side of the page. The first section displays the 'Selected filters'. 2nd section displays 3 free text search fields. First is the 'Email' search field. When the correct search criteria is inputted and run, then the user list is updated to display matching results; however, where the wrong email is inputted, the following error message is displayed; '{**}There is a problem'. '{**}No users could be found matching your search criteria. Try adjusting or clearing the filters.' The second and third search fields are the 'User ID' and 'User Provenance ID' fields which display the following descriptive message beneath the field name and above the search bar 'Must be an exact match'. The 3rd section provides 2 check box sub-sections titled 'Role' which has the following options, 'Verified, CTSC Admin, Local Admin and System Admin', and then 'Provenance' with the following options 'B2C, CFT IdAM, Crime IdAM and SSO'.
* Clicking on the 'Manage' link beside a user takes the system admin to the 'Manage <user email>' page which displays a caution symbol underneath the page title with the following descriptive message beside it; '{**}Ensure authorisation has been granted before updating this user'{**}. The following rows are provided in a displayed table; 'User ID, Email, Role. Provenance, Provenance ID, Creation Date and Last sign in'. This is followed by a red 'Delete user' button.
🧰 Tools
🪛 LanguageTool

[grammar] ~28-~28: The verb form ‘is’ does not seem to match the subject ‘criteria’.
Context: ...field. When the correct search criteria is inputted and run, then the user list is...

(SUBJECT_VERB_AGREEMENT_PLURAL)


[misspelling] ~28-~28: This word is normally spelled as one.
Context: ...'. The 3rd section provides 2 check box sub-sections titled 'Role' which has the following o...

(EN_COMPOUNDS_SUB_SECTIONS)


[style] ~29-~29: Would you like to use the Oxford spelling “authorization”? The spelling ‘authorisation’ is also correct.
Context: ...criptive message beside it; '{**}Ensure authorisation has been granted before updating this u...

(OXFORD_SPELLING_Z_NOT_S)

🪛 markdownlint-cli2 (0.20.0)

[warning] 28-28: Reference links and images should use a label that is defined
Missing link or image reference definition: "-"

(MD052, reference-links-images)

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Feb 3, 2026

🎭 Playwright E2E Test Results

250 tests   250 ✅  24m 15s ⏱️
 34 suites    0 💤
  1 files      0 ❌

Results for commit 5b579c5.

♻️ This comment has been updated with latest results.

Implements end-to-end testing for issue #310 covering:
- User search and filtering (email, roles, provenances, user ID)
- Filter tag display, removal, and clear functionality
- User management page navigation
- User deletion workflow with confirmation
- Cancel deletion functionality
- Empty results state
- Accessibility testing with Axe at each page
- Welsh translation verification throughout journey
- Keyboard navigation and validation error handling

Tests follow E2E guidelines by minimizing test count and including all
checks (accessibility, Welsh, validation) within complete user journeys.
All 4 tests passing locally with Playwright.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (4)
e2e-tests/tests/user-management.spec.ts (4)

39-40: Place top-level constants before helpers to match module ordering.
Move createdUsers above the helper declarations for consistent structure.

As per coding guidelines: Module ordering: constants outside function scope at top, exported functions next, other functions in usage order, interfaces and types at bottom.


42-57: Avoid duplicating test-user creation logic.
e2e-tests/utils/notification-helpers.ts:18-34 already defines createTestUser; consider extending it to accept role/provenance and reuse it here to keep seeding consistent.


105-116: Prefer role/label-based locators over CSS selectors.
This makes the tests less brittle and aligns with accessibility semantics; apply the same pattern for the filter tag and confirmation radios.

♻️ Example refactor
-    const filterTag = page.locator(".user-management-filter-tag").first();
-    await filterTag.click();
+    await page.getByRole("button", { name: /remove email filter/i }).click();

-    await page.locator('input[name="roles"][value="VERIFIED"]').check();
-    await page.locator('input[name="provenances"][value="CFT_IDAM"]').check();
+    await page.getByRole("checkbox", { name: /verified/i }).check();
+    await page.getByRole("checkbox", { name: /cft idam/i }).check();

-    await page.locator('input[name="confirmation"][value="yes"]').check();
+    await page.getByRole("radio", { name: /^yes$/i }).check();

As per coding guidelines: E2E tests should use getByRole() first, getByLabel() for inputs, getByText() for text, and getByTestId() as last resort.

Also applies to: 193-194, 230-231


210-285: Consolidate partial journeys or add inline Welsh/A11y checks.
The additional tests split the journey and omit the Welsh/Axe checks performed in the main test; consider folding these into the primary journey (or adding the inline checks) to match the single-journey guidance.

As per coding guidelines: E2E tests in Playwright should minimise test count with one test per complete user journey, including validations, Welsh translations, and accessibility checks inline rather than in separate tests.

alexbottenberg and others added 6 commits February 4, 2026 10:40
Updates package resolutions to address 3 known vulnerabilities:
- @isaacs/brace-expansion: 5.0.0 → 5.0.1 (Critical, CVSS 9.2)
  Fixes GHSA-7h2j-956f-4vf2
- lodash: 4.17.21 → 4.17.23 (High, CVSS 6.9)
  Fixes GHSA-xxjr-mmjv-4gpg
- tar: 7.5.4 → 7.5.7 (Medium, CVSS 8.2)
  Fixes GHSA-34x7-hfp2-rc4v

These are transitive dependencies resolved via yarn resolutions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Updates the visual presentation of filter sections:
- Move outline from individual sections to wrapper for cleaner appearance
- Change header background from #b1b4b6 to lighter #f3f2f1
- Add bottom borders to header and selected filters sections for separation
- Remove background color from selected filters section

This creates a more cohesive visual hierarchy with a single container
outline and subtle section dividers.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Updates tests for manage-user and delete-user-confirm pages after
restructuring to use dynamic [userId] parameters:

- Fix import paths: Change from ../../ to ../../../ to account for
  additional directory nesting with [userId] subdirectories
- Update render path expectations: Change from "manage-user/index"
  to "manage-user/[userId]/index" and similar for delete-user-confirm
- Update manage-user test to expect formatted date strings instead of
  Date objects, as the controller now formats dates before rendering

All 342 tests now passing.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Consolidates filter-related routes into the find-users directory structure:
- Move /find-users-clear-filters to /find-users/clear-filters
- Move /find-users-remove-filter to /find-users/remove-filter

This creates a more logical hierarchical structure where all find-users
functionality is grouped together, making the codebase easier to navigate.

Changes:
- Created find-users/clear-filters/index.ts subdirectory
- Created find-users/remove-filter/index.ts subdirectory
- Updated URLs in find-users/index.ts and index.njk
- Removed old top-level directories

All 342 tests passing. URLs updated to use forward slashes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added 12 new tests to find-users controller (18 tests total):
- Database error handling with graceful degradation
- Query string pagination parameter handling
- Welsh content rendering (lng=cy)
- Pagination with previous/next links
- Filter tag generation for all filter types
- User rows generation with manage links
- Welsh language persistence in pagination URLs
- Input whitespace trimming
- Single value to array conversion for roles/provenances
- Session initialization when userManagement missing
- Welsh validation error rendering

Also removed old top-level route files that were reorganized
into find-users subdirectories in previous commit.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Updated system-admin-dashboard E2E tests to match actual implementation:
- User Management tile links to /find-users (not /user-management)
- Tile description is "Find, update and delete users" (not "Search...")

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (11)
e2e-tests/tests/system-admin-dashboard.spec.ts (5)

21-21: Avoid testing CSS classes.

Testing .govuk-heading-l checks visual styling rather than functionality. Consider removing this assertion or replacing with a role-based check.

Suggested change
-      await expect(heading).toHaveClass(/govuk-heading-l/);

Based on learnings: "Do NOT test visual styling (font sizes, background colors, margins, padding) or UI design aspects in E2E tests."


62-65: Grid layout test checks visual styling.

Testing the 2-column grid structure verifies UI design rather than user functionality. Consider removing this test.

Based on learnings: "Do NOT test visual styling (font sizes, background colors, margins, padding) or UI design aspects in E2E tests."


68-72: Consider inlining accessibility checks into journey tests.

Per coding guidelines, accessibility checks using AxeBuilder should be inline within journey tests rather than in separate dedicated tests. This reduces test count and ensures accessibility is validated as part of the user flow.

Based on learnings: "E2E tests in Playwright should minimize test count with one test per complete user journey, including validations, Welsh translations, and accessibility checks inline rather than in separate tests."


12-66: Consider consolidating tests into fewer journey-based tests.

The "Content Display" describe block contains 6 separate tests that could be consolidated. Per guidelines, E2E tests should minimise test count with one test per complete user journey. This would reduce test execution overhead and align with the project's testing strategy.

As per coding guidelines: "E2E tests in Playwright should minimize test count with one test per complete user journey."


41-45: Prefer getByRole() over CSS selectors.

The guidelines recommend using getByRole() first, then getByLabel() for inputs, getByText() for text content, with getByTestId() as a last resort. The current CSS selector approach (a.admin-tile:has-text(...)) works but is less resilient to implementation changes.

Example using getByRole
-        const link = page.locator(`a.admin-tile:has-text("${title}")`);
+        const link = page.getByRole("link", { name: title });

As per coding guidelines: "use getByRole() first, getByLabel() for inputs, getByText() for text, and getByTestId() as last resort."

libs/system-admin-pages/src/pages/delete-user-confirm/[userId]/index.test.ts (1)

14-14: Consider adding a type for mockUser instead of any.

A simple inline type or interface would improve type safety and make the test more self-documenting.

Suggested improvement
-  let mockUser: any;
+  let mockUser: { userId: string; email: string };
libs/system-admin-pages/src/pages/find-users/index.test.ts (1)

1-28: Avoid any for the mock session.

Using any hides type drift; a minimal session type keeps the tests aligned with the handler contract.

Proposed refactor
 import { beforeEach, describe, expect, it, vi } from "vitest";
 import * as queries from "../../user-management/queries.js";
 import { GET, POST } from "./index.js";
 
+type MockUserManagementSession = {
+  userManagement?: {
+    filters?: {
+      email?: string;
+      userId?: string;
+      userProvenanceId?: string;
+      roles?: string[];
+      provenances?: string[];
+    };
+    page?: number;
+  };
+};
+
 describe("find-users page", () => {
   let mockRequest: Partial<Request>;
   let mockResponse: Partial<Response>;
-  let mockSession: any;
+  let mockSession: MockUserManagementSession;

As per coding guidelines: Use TypeScript strict mode. Avoid any without justification.

libs/system-admin-pages/src/pages/find-users/clear-filters/index.ts (1)

1-15: Move UserManagementSession below handlers to match module ordering.

This keeps interfaces/types at the bottom as required.

Proposed refactor
 import { requireRole, USER_ROLES } from "@hmcts/auth";
 import type { Request, RequestHandler, Response } from "express";
-
-interface UserManagementSession {
-  userManagement?: {
-    filters?: {
-      email?: string;
-      userId?: string;
-      userProvenanceId?: string;
-      roles?: string[];
-      provenances?: string[];
-    };
-    page?: number;
-  };
-}
 
 const getHandler = async (req: Request, res: Response) => {
   const language = req.query.lng === "cy" ? "cy" : "en";
   const session = req.session as UserManagementSession;
@@
 export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler];
+
+interface UserManagementSession {
+  userManagement?: {
+    filters?: {
+      email?: string;
+      userId?: string;
+      userProvenanceId?: string;
+      roles?: string[];
+      provenances?: string[];
+    };
+    page?: number;
+  };
+}

As per coding guidelines: Module ordering: constants outside function scope at top, exported functions next, other functions in usage order, interfaces and types at bottom.

libs/system-admin-pages/src/pages/find-users/remove-filter/index.ts (1)

1-15: Move UserManagementSession below handlers to match module ordering.

This aligns type placement with the project’s ordering rules.

Proposed refactor
 import { requireRole, USER_ROLES } from "@hmcts/auth";
 import type { Request, RequestHandler, Response } from "express";
-
-interface UserManagementSession {
-  userManagement?: {
-    filters?: {
-      email?: string;
-      userId?: string;
-      userProvenanceId?: string;
-      roles?: string[];
-      provenances?: string[];
-    };
-    page?: number;
-  };
-}
 
 const getHandler = async (req: Request, res: Response) => {
@@
 export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler];
+
+interface UserManagementSession {
+  userManagement?: {
+    filters?: {
+      email?: string;
+      userId?: string;
+      userProvenanceId?: string;
+      roles?: string[];
+      provenances?: string[];
+    };
+    page?: number;
+  };
+}

As per coding guidelines: Module ordering: constants outside function scope at top, exported functions next, other functions in usage order, interfaces and types at bottom.

libs/system-admin-pages/src/pages/find-users/index.ts (2)

8-19: Move UserManagementSession below handlers to match module ordering.

This keeps interfaces/types at the bottom as required.

Proposed refactor
 import { cy } from "./cy.js";
 import { en } from "./en.js";
-
-interface UserManagementSession {
-  userManagement?: {
-    filters?: {
-      email?: string;
-      userId?: string;
-      userProvenanceId?: string;
-      roles?: string[];
-      provenances?: string[];
-    };
-    page?: number;
-  };
-}
 
 const getHandler = async (req: Request, res: Response) => {
@@
 export const GET: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), getHandler];
 export const POST: RequestHandler[] = [requireRole([USER_ROLES.SYSTEM_ADMIN]), postHandler];
+
+interface UserManagementSession {
+  userManagement?: {
+    filters?: {
+      email?: string;
+      userId?: string;
+      userProvenanceId?: string;
+      roles?: string[];
+      provenances?: string[];
+    };
+    page?: number;
+  };
+}

As per coding guidelines: Module ordering: constants outside function scope at top, exported functions next, other functions in usage order, interfaces and types at bottom.


60-61: Add explicit types for selectedFilterGroups/paginationItems.

Empty array literals introduce implicit any[] under strict mode; explicit types keep the handler safe and self-documenting.

Proposed refactor
-  const selectedFilterGroups = [];
+  const selectedFilterGroups: Array<{
+    heading: string;
+    tags: Array<{ label: string; removeUrl: string }>;
+  }> = [];
@@
-  const paginationItems = [];
+  const paginationItems: Array<{
+    number: number;
+    href: string;
+    current?: boolean;
+    visuallyHiddenText?: string;
+  }> = [];

As per coding guidelines: Use TypeScript strict mode. Avoid any without justification.

Also applies to: 139-140

Comment on lines +132 to +137
const userRows = searchResult.users.map((user) => [
{ text: user.email },
{ text: user.role },
{ text: user.userProvenance },
{ html: `<a href="/manage-user/${user.userId}${lngParam}" class="govuk-link">${content.manageLink}</a>` }
]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

User rows omit required fields and Welsh labels.

The list only outputs email/role/provenance/manage and uses raw enum values. The PR objectives require User ID, Provenance ID, Created, and Last sign-in columns, and role/provenance values should be localised (especially for lng=cy). Please extend userRows and reuse the role/provenance label mapping for i18n.

alexbottenberg and others added 11 commits February 4, 2026 12:04
Changed expected property from 'users' to 'userRows' to match
actual render call. The controller maps users to rows before
rendering, so the test should check for userRows instead.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Removed toHaveClass check for .govuk-heading-l as E2E tests should
not test visual styling. The test already verifies the heading is
visible and contains the correct text, which are the functional
requirements.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Removed test that checks for 2-column grid structure as it tests
visual styling/layout rather than user functionality. E2E tests
should focus on user behavior and content, not UI design details.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Refactored system-admin-dashboard E2E tests to follow best practices:
- Combined 10+ separate tests into 1 comprehensive journey test
- Inlined accessibility checks (WCAG 2.2 AA) within the main journey
- Included keyboard navigation testing in the journey flow
- Added tile click navigation to verify complete user flow
- Removed visual styling tests (CSS classes, grid layout, hover states)
- Kept responsive design tests as @nightly (different viewports)

The single journey test now covers:
- Page load and title verification
- Content verification (heading, 8 tiles with titles/links/descriptions)
- Accessibility validation inline
- Keyboard navigation functionality
- Complete navigation flow by clicking a tile

This follows E2E testing guidelines: minimize test count with one test
per journey, include accessibility/validation inline rather than separate.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Removed 3 viewport-specific tests (mobile, tablet, desktop) as they
test visual layout/responsiveness rather than user functionality.

E2E tests should focus on user journeys and behavior, not visual
presentation at different screen sizes. Responsive design is the
responsibility of CSS and the GOV.UK Design System.

Test count reduced from 4 to 1 comprehensive journey test.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Replaced CSS selectors with accessibility-focused getByRole() selectors:
- Changed h1 locators to getByRole("heading", { level: 1 })
- Changed link selectors from CSS (.admin-tile, a:has-text) to getByRole("link")
- Removed redundant tile count check
- Simplified description checks by verifying content within link elements
- Updated keyboard navigation to use getByRole()

getByRole() is more resilient to implementation changes and follows
accessibility best practices. Selector priority: getByRole() > getByLabel()
> getByText() > getByTestId().

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Replaced `any` type for mockUser with proper MockUser interface.
This improves type safety and makes the test more self-documenting.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The previous logic checked Object.keys(filters).length which is always
greater than 0 since the filters object always has keys (email, userId,
etc.), even when values are undefined. This caused the no-results error
to appear even when no filters were actually applied.

Changed to check if any filter has an active value:
- For arrays: check if length > 0
- For other values: check if truthy

Added tests to verify:
- No error shown when all filters are undefined
- No error shown when array filters are empty
- Error still shown when active filters return no results

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Applied the same role and provenance label mappings to the table
display that were already being used for filter tags. This ensures:
- Roles display as "System Admin" instead of "SYSTEM_ADMIN"
- Provenances display as "CFT IdAM" instead of "CFT_IDAM"
- All labels are properly localized for Welsh users

The table keeps the simple 4-column layout (Email, Role, Provenance, Manage)
without additional columns for User ID, Provenance ID, Created, or Last Sign-in.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Updated test expectations to match the localized labels:
- "VERIFIED" -> "Verified"
- "CFT_IDAM" -> "CFT IdAM"

Added test to verify Welsh localization works correctly:
- "SYSTEM_ADMIN" -> "Gweinyddwr System" when lng=cy

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
The GOV.UK pagination component expects previous/next as separate
top-level properties, not as items in the paginationItems array.

Changes:
- Separated previous and next into paginationPrevious and paginationNext
- paginationItems now only contains numbered page objects
- paginationPrevious/Next are undefined when not applicable (first/last page)
- Updated template to pass all three properties to govukPagination
- Updated tests to verify correct structure
- Added tests for edge cases (first page, last page)

This follows the GOV.UK Design System specification where:
- previous: { href: "..." } (optional)
- next: { href: "..." } (optional)
- items: [{ number: 1, href: "...", current: false }, ...]

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Comment on lines +27 to +28
const page = Number(req.query.page) || session.userManagement?.page || 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalise page to a positive integer.

Number(req.query.page) can be negative, fractional, or NaN; negative pages lead to invalid pagination/skips. Clamp to a positive integer before calling searchUsers.

✅ Proposed fix
-  const page = Number(req.query.page) || session.userManagement?.page || 1;
+  const pageParam = Number(req.query.page);
+  const page =
+    Number.isFinite(pageParam) && pageParam > 0
+      ? Math.floor(pageParam)
+      : session.userManagement?.page || 1;

Added 50 test users with varied roles and provenances:
- Roles: VERIFIED, INTERNAL_ADMIN_CTSC, INTERNAL_ADMIN_LOCAL, SYSTEM_ADMIN
- Provenances: CFT_IDAM, SSO, B2C, CRIME_IDAM
- Staggered creation dates (1 day apart)
- Some users have never signed in (lastSignedInDate = null)
- Fixed UUIDs: 10000000-0000-0000-0000-000000000001 to 000000000050

With 25 users per page, this creates 2 pages for testing pagination.

Run: cd apps/postgres && npx tsx prisma/seed.ts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/postgres/prisma/seed.ts (1)

93-104: Consider batching database operations.

Sequential findUnique and create calls result in 100 round-trips. For seed scripts, createMany with skipDuplicates or a transaction-wrapped approach would be more efficient, though this is acceptable for development tooling.

Alternative using createMany
-  for (const user of testUsers) {
-    const existing = await prisma.user.findUnique({
-      where: { userId: user.userId }
-    });
-
-    if (!existing) {
-      await prisma.user.create({ data: user });
-      console.log(`Created test user: ${user.email}`);
-    } else {
-      console.log(`User ${user.email} already exists`);
-    }
-  }
+  const result = await prisma.user.createMany({
+    data: testUsers,
+    skipDuplicates: true
+  });
+  console.log(`Created ${result.count} test users (${testUsers.length - result.count} already existed).`);

alexbottenberg and others added 2 commits February 4, 2026 14:11
Fixed delete-user-success template using correct Nunjucks if syntax
instead of ternary operator which caused template render errors.

Changed from: {{ lng ? '?lng=' + lng : '' }}
To: {% if lng %}?lng={{ lng }}{% endif %}

Also removed debug console.log from find-users controller.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Added proper cascade deletion to handle foreign key constraints:

1. Find all subscriptions for the user
2. Delete NotificationAuditLog records linked to those subscriptions
3. Delete the subscriptions
4. Delete the user

This prevents foreign key constraint violations since NotificationAuditLog
references Subscription (via subscriptionId) without cascade delete configured.

Added tests:
- Verify audit logs are deleted before subscriptions
- Verify audit log deletion is skipped when user has no subscriptions
- Verify correct order of operations in transaction

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
libs/system-admin-pages/src/user-management/queries.ts (1)

4-31: Align module ordering with the project guidelines.

Constants should be at the top and interfaces/types at the bottom. Please reorder PAGE_SIZE above exports and move the interfaces to the bottom of the file.

♻️ Suggested re-ordering
 import { prisma } from "@hmcts/postgres";
 import type { Prisma } from "@prisma/client";

- interface UserSearchFilters {
-   email?: string;
-   userId?: string;
-   userProvenanceId?: string;
-   roles?: string[];
-   provenances?: string[];
- }
-
- interface UserSearchResult {
-   users: User[];
-   totalCount: number;
-   currentPage: number;
-   totalPages: number;
- }
-
- interface User {
-   userId: string;
-   email: string;
-   firstName: string | null;
-   surname: string | null;
-   userProvenance: string;
-   userProvenanceId: string;
-   role: string;
-   createdDate: Date;
-   lastSignedInDate: Date | null;
- }
-
 const PAGE_SIZE = 25;

 export async function searchUsers(filters: UserSearchFilters, page: number = 1): Promise<UserSearchResult> {
   ...
 }

 export async function getUserById(userId: string): Promise<User | null> {
   ...
 }

 export async function deleteUserById(userId: string): Promise<void> {
   ...
 }
+
+interface UserSearchFilters {
+  email?: string;
+  userId?: string;
+  userProvenanceId?: string;
+  roles?: string[];
+  provenances?: string[];
+}
+
+interface UserSearchResult {
+  users: User[];
+  totalCount: number;
+  currentPage: number;
+  totalPages: number;
+}
+
+interface User {
+  userId: string;
+  email: string;
+  firstName: string | null;
+  surname: string | null;
+  userProvenance: string;
+  userProvenanceId: string;
+  role: string;
+  createdDate: Date;
+  lastSignedInDate: Date | null;
+}

Comment on lines +33 to +35
export async function searchUsers(filters: UserSearchFilters, page: number = 1): Promise<UserSearchResult> {
const skip = (page - 1) * PAGE_SIZE;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard against page <= 0 to avoid negative skip.

If a caller passes 0/negative, Prisma will throw due to a negative skip. Consider clamping or throwing early.

✅ Safer pagination normalisation
-export async function searchUsers(filters: UserSearchFilters, page: number = 1): Promise<UserSearchResult> {
-  const skip = (page - 1) * PAGE_SIZE;
+export async function searchUsers(filters: UserSearchFilters, page: number = 1): Promise<UserSearchResult> {
+  const safePage = Math.max(1, page);
+  const skip = (safePage - 1) * PAGE_SIZE;
   ...
   return {
     users,
     totalCount,
-    currentPage: page,
+    currentPage: safePage,
     totalPages
   };
 }

Line 39 was injecting user-supplied filter labels (email, userId,
userProvenanceId) directly into HTML without escaping. This created
an XSS attack vector where malicious input like:

  email: <script>alert('XSS')</script>@example.com

Would execute arbitrary JavaScript when displayed as a filter tag.

Fixed by using Nunjucks' escape filter: (tag.label | escape)

This ensures all user input is HTML-encoded before rendering.
Line 213 was calling searchUsers({}, 1) without try-catch during
validation error handling. If the database query failed, the request
would crash instead of gracefully handling the error.

Now wraps the searchUsers call in try-catch with:
- Error logging with context (filters, timestamp)
- Fallback to empty result set
- Consistent error handling pattern with GET handler

This ensures users still see validation errors even if the
database query fails.
Moved validateEnvVars() call from line 28 to line 33, inside the
SSO redirect check. This prevents test failures in local/non-SSO
environments where SSO credentials aren't required.

Before:
- validateEnvVars() called before navigation
- Failed immediately if env vars missing, even for local dev

After:
- validateEnvVars() only called when SSO redirect detected
- Allows tests to run locally without SSO credentials
- Only validates when SSO is actually needed
Line 106 was logging 'Created 50 test users' even when some were
skipped due to existing records, providing inaccurate reporting.

Changes:
- Added createdCount and skippedCount tracking
- Increment counters based on actual create/skip operations
- Updated final log message to show both counts

Example output:
  Before: 'Seed completed successfully! Created 50 test users.'
  After:  'Seed completed successfully! Created 3 test users, skipped 47 existing users.'

This provides accurate feedback on what the seed script actually did.
- Add type annotation for searchResult variable in POST handler
- Fix import order in user-management E2E test
- Apply biome formatting (line length adjustments)
- Use template literal instead of string concatenation

All files now pass biome lint checks.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
e2e-tests/tests/user-management.spec.ts (2)

210-285: Consider folding the extra @nightly tests into the main journey.
The first test already covers a full end‑to‑end flow (incl. Welsh + a11y). Keeping the remaining scenarios in separate tests increases count against the single‑journey guidance; consider merging them unless isolation is required.

Based on learnings E2E tests in Playwright should minimize test count with one test per complete user journey, including validations, Welsh translations, and accessibility checks inline rather than in separate tests.


106-116: Prefer role/label locators over CSS selectors for form controls.
The CSS selectors for checkboxes/radios are brittle and bypass accessible names; getByRole/getByLabel keeps tests resilient and aligned to a11y.

Suggested locator updates
-    await page.locator('input[name="roles"][value="VERIFIED"]').check();
-    await page.locator('input[name="provenances"][value="CFT_IDAM"]').check();
+    await page.getByRole("checkbox", { name: "Verified" }).check();
+    await page.getByRole("checkbox", { name: "CFT IdAM" }).check();
...
-    await page.locator('input[name="confirmation"][value="yes"]').check();
+    await page.getByRole("radio", { name: "Yes" }).check();
...
-    await page.locator('input[name="confirmation"][value="no"]').check();
+    await page.getByRole("radio", { name: "No" }).check();

Based on learnings E2E tests should tag nightly-only tests with nightly in the test title and use getByRole() first, getByLabel() for inputs, getByText() for text, and getByTestId() as last resort.

Also applies to: 192-194, 229-231

Comment on lines +244 to +268
test("system admin can filter by user ID @nightly", async ({ page }) => {
// Create test user
const testEmail = `userid.test.${Date.now()}@example.com`;
const testUserId = await createTestUser(testEmail, "SYSTEM_ADMIN", "CRIME_IDAM");

// Authenticate as system admin
await authenticateSystemAdmin(page);

// Navigate to find users
await page.getByRole("link", { name: "User Management" }).click();

// Note: User ID filter has validation that requires alphanumeric only,
// but UUIDs contain hyphens, so this test verifies that results are shown
// Search by email instead to find the specific user
await page.getByLabel("Email").fill(testEmail);
await page.getByRole("button", { name: "Apply filters" }).click();
await page.waitForLoadState("networkidle");

// Verify the specific user is found
await expect(page.getByText(/1 users? found/)).toBeVisible();

// Verify we can access the user details
await page.getByRole("link", { name: "Manage" }).first().click();
await expect(page.getByRole("heading", { name: `Manage ${testEmail}`, level: 1 })).toBeVisible();
await expect(page.getByText(testUserId)).toBeVisible();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and examine the test
fd "user-management.spec.ts" e2e-tests

Repository: hmcts/cath-service

Length of output: 101


🏁 Script executed:

# Read the specific test file and lines around 244-268
cat -n e2e-tests/tests/user-management.spec.ts | sed -n '240,275p'

Repository: hmcts/cath-service

Length of output: 1994


🏁 Script executed:

# Search for "User ID" field usage in the e2e tests
rg "User ID" e2e-tests --context 2

Repository: hmcts/cath-service

Length of output: 1062


🏁 Script executed:

# Check if there are other tests in the same file that use filters
rg "getByLabel" e2e-tests/tests/user-management.spec.ts --context 1

Repository: hmcts/cath-service

Length of output: 1150


Rename test to match what it actually exercises.

The test is titled "filter by user ID" but filters by email. The code comment correctly explains why: the User ID field validates alphanumeric input only and rejects UUIDs containing hyphens. Renaming the test to "system admin can filter by email" or similar would align the title with the actual user journey being tested.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (14)
libs/verified-pages/src/pages/subscription-locations-review/index.njk-32-39 (1)

32-39: ⚠️ Potential issue | 🟡 Minor

Remove links lack unique accessible names for screen readers.

Every row renders identical link text (removeLink = "Remove"), making it impossible for assistive technology users to distinguish which location each link targets. Add visually hidden text with the location name.

♿ Proposed fix
             <td class="govuk-table__cell">
-              <a href="/subscription-locations-review?remove={{ location.locationId }}" class="govuk-link">{{ removeLink }}</a>
+              <a href="/subscription-locations-review?remove={{ location.locationId }}" class="govuk-link">
+                {{ removeLink }}<span class="govuk-visually-hidden"> {{ location.name }}</span>
+              </a>
             </td>
e2e-tests/tests/user-management.spec.ts-40-54 (1)

40-54: ⚠️ Potential issue | 🟡 Minor

substr is deprecated; use substring instead.

String.prototype.substr is deprecated. Replace with substring.

Proposed fix
-      userProvenanceId: `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
+      userProvenanceId: `test-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
e2e-tests/tests/user-management.spec.ts-204-204 (1)

204-204: ⚠️ Potential issue | 🟡 Minor

Change "B2C" to "B2C_IDAM" at line 204. The TypeScript model and codebase define four valid provenance values: "SSO", "CFT_IDAM", "CRIME_IDAM", and "B2C_IDAM". Line 204 uses the invalid value "B2C"; line 238 correctly uses "CRIME_IDAM".

libs/verified-pages/src/pages/subscription-list-language/index.test.ts-139-156 (1)

139-156: ⚠️ Potential issue | 🟡 Minor

Note: Welsh error text may be incorrect (see cy.ts review).

The assertion at line 150 matches the potentially malformed errorRequired string from cy.ts. If that string is corrected, this assertion will need updating too.

libs/verified-pages/src/pages/subscription-list-language/cy.ts-9-9 (1)

9-9: ⚠️ Potential issue | 🟡 Minor

Possibly malformed Welsh error message.

errorRequired appears to concatenate two phrases without punctuation: roughly "Select a version of the list type" + "Select an option". This reads like a copy-paste artefact. Compare with the English equivalent which would be a single coherent sentence. Please verify the intended Welsh translation.

libs/verified-pages/src/pages/delete-list-type-subscription/index.ts-16-20 (1)

16-20: ⚠️ Potential issue | 🟡 Minor

Silent failure on deletion error — user receives no feedback.

When deleteListTypeSubscription throws, the error is logged but the user is silently redirected back to /subscription-management with no indication that the deletion failed. Consider passing an error state via session/flash so the management page can display a notification.

libs/verified-pages/src/pages/subscription-list-language/en.ts-9-9 (1)

9-9: ⚠️ Potential issue | 🟡 Minor

Error message style inconsistency and missing article.

"Please select version of the list type to continue" — missing "a" before "version", and uses "Please" which differs from other error messages in the codebase (e.g., subscription-add-method/en.ts uses the imperative "Select how you want to..." without "Please"), aligning with GOV.UK error message conventions.

Suggested wording
-  errorRequired: "Please select version of the list type to continue",
+  errorRequired: "Select a version of the list type to continue",
libs/verified-pages/src/pages/subscription-confirm/index.njk-61-74 (1)

61-74: ⚠️ Potential issue | 🟡 Minor

Empty table rendered when there are no list types.

When hasNoListTypes is true, the govukTable at lines 61–68 still renders (with presumably empty listTypeRows), showing table headers with no content. Consider wrapping the table in a conditional or showing a "no list types selected" message instead.

Proposed fix
+{% if not hasNoListTypes %}
 {{ govukTable({
   head: [
     { text: listTypesHeading },
     { text: actionsHeading, classes: "govuk-table__header--numeric" }
   ],
   rows: listTypeRows,
   classes: "subscription-confirm-table"
 }) }}
+{% endif %}

 {% if hasNoListTypes %}
   <p class="govuk-body">
     <a href="/subscription-list-types" class="govuk-link">{{ selectListTypesLink }}</a>
   </p>
 {% endif %}
libs/verified-pages/src/pages/subscription-management/index.njk-82-83 (1)

82-83: ⚠️ Potential issue | 🟡 Minor

Hardcoded English aria-label breaks bilingual accessibility.

The aria-label on line 82 is always in English. Welsh-language users will hear English text from their screen reader. Consider using a translatable string variable instead.

Proposed fix
-                <button type="submit" class="govuk-link govuk-button-as-link" aria-label="Remove subscription for {{ subscription.listTypeName }}">
+                <button type="submit" class="govuk-link govuk-button-as-link" aria-label="{{ removeListTypeAriaLabel }} {{ subscription.listTypeName }}">
libs/verified-pages/src/pages/subscription-by-location/index.njk-134-160 (1)

134-160: ⚠️ Potential issue | 🟡 Minor

Table missing <thead> and column headers — accessibility concern.

The acceptance criteria require WCAG 2.2 AA compliance with semantic table markup. This table has no <thead> or <th> elements, which means screen readers cannot associate cells with column headers.

Proposed fix
       <table class="govuk-table court-table">
+        <thead class="govuk-table__head govuk-visually-hidden">
+          <tr class="govuk-table__row">
+            <th class="govuk-table__header" scope="col">{{ letterHeading or "Letter" }}</th>
+            <th class="govuk-table__header" scope="col">{{ courtNameHeading or "Court or tribunal name" }}</th>
+          </tr>
+        </thead>
         <tbody class="govuk-table__body">
libs/verified-pages/src/pages/subscription-list-types/index.njk-39-51 (1)

39-51: ⚠️ Potential issue | 🟡 Minor

Duplicate <label> elements for the same input may confuse assistive technology.

Each checkbox has two <label> elements with for="listTypes-{{ item.value }}": one visually hidden (Line 43–44) and one visible (Line 50). While browsers associate the first for-matched label, multiple labels for one input can produce inconsistent screen reader announcements. Consider removing the hidden label and keeping only the visible one, or using aria-labelledby instead.

libs/verified-pages/src/pages/subscription-list-types/index.njk-31-56 (1)

31-56: ⚠️ Potential issue | 🟡 Minor

Table lacks <thead> and column headers.

For WCAG 2.2 AA compliance (a stated PR objective), data tables should have header cells (<th>) so that screen readers can associate data with column meanings. Adding a visually-hidden header row would satisfy this.

libs/verified-pages/src/pages/subscription-list-language/index.ts-37-48 (1)

37-48: ⚠️ Potential issue | 🟡 Minor

No validation of the language value.

The POST handler checks that language is truthy but doesn't validate it against the expected set (e.g., "ENGLISH", "WELSH", "BOTH"). Any arbitrary string from the request body will be persisted to the session.

Suggested fix
+ const VALID_LANGUAGES = ["ENGLISH", "WELSH", "BOTH"];
+
  if (!language) {
    ...
  }
+
+ if (!VALID_LANGUAGES.includes(language)) {
+   // treat as validation error, same as missing
+   ...
+ }
libs/verified-pages/src/pages/subscription-confirm/index.ts-173-183 (1)

173-183: ⚠️ Potential issue | 🟡 Minor

Dead code: hasNoListTypes check on line 180 is unreachable.

Line 174 already redirects when selectedListTypeIds.length === 0. The subsequent hasNoListTypes check at line 180 (which tests the same condition) can never be true. Simplify to only check hasNoLocations.

♻️ Proposed fix
   if (req.session.listTypeSubscription.selectedListTypeIds.length === 0) {
     return res.redirect("/subscription-list-types");
   }

-  // Check if locations or list types are empty (no subscriptions)
-  const hasNoLocations = !req.session.listTypeSubscription.selectedLocationIds || req.session.listTypeSubscription.selectedLocationIds.length === 0;
-  const hasNoListTypes = req.session.listTypeSubscription.selectedListTypeIds.length === 0;
-  if (hasNoLocations || hasNoListTypes) {
+  const hasNoLocations = !req.session.listTypeSubscription.selectedLocationIds || req.session.listTypeSubscription.selectedLocationIds.length === 0;
+  if (hasNoLocations) {
     return res.redirect("/subscription-add-method");
   }
🧹 Nitpick comments (28)
libs/web-core/src/assets/css/filter-panel.scss (1)

140-164: Consider using SCSS variables for repeated colour literals.

Colours like #1d70b8, #003078, and #0b0c0c appear multiple times across this file. Extracting them into variables (e.g., $govuk-link-colour) would reduce duplication and ease future updates.

libs/verified-pages/src/pages/subscription-locations-review/index.ts (1)

47-55: Sequential awaits — consider Promise.all for parallel fetching.

Each getLocationById call is awaited in series. If a user has selected many locations, this adds up linearly.

♻️ Proposed parallel fetch
-  const locationRows: LocationRow[] = [];
-  for (const locationId of selectedLocationIds) {
-    const location = await getLocationById(locationId);
-    if (location) {
-      locationRows.push({
-        locationId: location.locationId,
-        name: locale === "cy" ? location.welshName : location.name
-      });
-    }
-  }
+  const locations = await Promise.all(selectedLocationIds.map((id) => getLocationById(id)));
+  const locationRows: LocationRow[] = locations
+    .filter((location) => location !== null && location !== undefined)
+    .map((location) => ({
+      locationId: location.locationId,
+      name: locale === "cy" ? location.welshName : location.name
+    }));
docs/tickets/296/review.md (1)

1-459: Consider whether this AI-generated review document belongs in the repository.

Committing a lengthy AI-generated code review as a versioned file is unusual. This type of artefact is typically ephemeral (PR comments, wiki pages) rather than part of the source tree. It will grow stale quickly and isn't maintained alongside the code it describes. Consider moving it to a wiki, PR comment, or ADR format if the intent is to preserve the decision record.

e2e-tests/tests/user-management.spec.ts (1)

107-111: Prefer getByRole/getByLabel over CSS selectors for checkboxes and radios.

Per coding guidelines, E2E tests should use getByRole() first, getByLabel() for inputs, and getByTestId() as a last resort. The CSS locators on Lines 109–110 (and similarly Lines 184, 221) could be replaced with getByLabel if the checkbox/radio labels are unique. If the labels aren't sufficiently distinct, this is acceptable as-is.

libs/subscription-list-types/src/locales/cy.ts (1)

1-6: Minor naming inconsistency: backLink vs backLinkText.

This shared locale uses backLink (Line 3), but the page-specific locale at libs/verified-pages/src/pages/subscription-list-types/cy.ts uses backLinkText for the same Welsh string "Yn ôl". Consider aligning the key names across locales to avoid confusion when composing content objects.

libs/verified-pages/src/pages/subscription-list-types/en.ts (1)

1-15: GOV.UK error message style convention.

Lines 12 and 14 use "Please select…" phrasing. GOV.UK Design System guidance typically recommends direct imperative phrasing without "Please" (e.g., "Select a list type to continue"). This is a minor style point — the Welsh counterpart in cy.ts already uses the direct imperative form ("Dewiswch...").

libs/verified-pages/src/pages/subscription-add-method/index.test.ts (2)

15-25: Consider adding session to mockReq.

If the handler (or a future change) reads from req.session (e.g., to restore previous selection or store list-type subscription state), the missing session property will cause a runtime error in tests. Worth adding an empty object now for resilience.

Proposed change
     mockReq = {
       body: {},
-      path: "/subscription-add-method"
+      path: "/subscription-add-method",
+      session: {} as any
     };

58-81: No test for an unrecognised subscriptionMethod value.

The "case" and "reference" options both redirect to /subscription-management. Consider adding a test with an unexpected value (e.g., "invalid") to document the handler's fallback behaviour.

apps/postgres/prisma/migrations/20260205161949_add_subscription_list_type/migration.sql (1)

13-19: Redundant index idx_subscription_list_type_user.

The unique composite index on (user_id, list_type_id, language) at line 19 already has user_id as its leftmost column, so PostgreSQL can use it for queries filtering by user_id alone. The standalone index at line 13 is redundant and adds write overhead without benefit.

🔧 Proposed fix
--- Remove the redundant single-column index
-CREATE INDEX "idx_subscription_list_type_user" ON "subscription_list_type"("user_id");
-
libs/verified-pages/src/pages/subscription-by-location/index.test.ts (1)

40-56: No Welsh locale test for GET handler.

Other page tests in this PR (e.g. subscription-list-language/index.test.ts) verify Welsh content when locale is cy. This test only covers the English path. As per coding guidelines, every page must support both English and Welsh, and templates should test with ?lng=cy query parameter.

libs/verified-pages/src/pages/subscription-confirm/index.njk (1)

15-32: Inline styles should be in a separate stylesheet.

Embedding <style> blocks inside templates makes styles harder to maintain and cache. Consider moving these to a co-located .scss file.

libs/verified-pages/src/pages/delete-list-type-subscription/index.ts (1)

19-19: Use structured logger instead of console.error.

The PR objectives mention enhanced structured error logging. Using console.error here is inconsistent with that goal. If a shared logger exists in the project, prefer that.

#!/bin/bash
# Check if there's a structured logger used elsewhere
rg -n "import.*logger" --type=ts -l | head -10
rg -n "from.*@hmcts.*log" --type=ts | head -10
libs/verified-pages/src/pages/subscription-by-location/index.njk (1)

27-35: Prefer CSS classes over inline styles.

Lines 27, 30, and 35 use inline style attributes for borders and background colours. These should live in a stylesheet (e.g., alongside user-management.scss) for maintainability and stricter CSP policies.

libs/verified-pages/src/pages/subscription-management/index.ts (1)

35-48: Language display map is duplicated across files.

The languageDisplay mapping (ENGLISH/WELSH/BOTH → localised strings) appears here and is likely repeated in other pages (e.g., subscription-confirm). Consider extracting it into the shared locale files or a small utility.

libs/verified-pages/src/pages/subscription-add-method/index.ts (2)

44-56: Redundant redirect branches.

"case", "reference", and the fallback all redirect to /subscription-management. This could be a single if/else.

Suggested simplification
-  if (subscriptionMethod === "court") {
-    return res.redirect("/subscription-by-location");
-  }
-
-  if (subscriptionMethod === "case") {
-    return res.redirect("/subscription-management");
-  }
-
-  if (subscriptionMethod === "reference") {
-    return res.redirect("/subscription-management");
-  }
-
-  return res.redirect("/subscription-management");
+  if (subscriptionMethod === "court") {
+    return res.redirect("/subscription-by-location");
+  }
+
+  return res.redirect("/subscription-management");

11-13: Guard around delete is unnecessary.

delete req.session.listTypeSubscription is safe even when the property doesn't exist — the if check on Line 11 is redundant.

libs/verified-pages/src/pages/subscription-list-language/index.ts (1)

58-58: Use structured logger instead of console.error.

The PR objectives mention enhanced structured error logging. Replace console.error with the project's structured logger for consistency and observability.

libs/verified-pages/src/pages/subscription-list-types/index.ts (1)

134-134: Use structured logger instead of console.error.

Same as in subscription-list-language/index.ts — replace with the project's logger.

libs/verified-pages/src/pages/subscription-list-types/index.njk (1)

36-49: Heavy use of inline styles.

Multiple cells use inline style attributes for width, alignment, and sizing. Prefer dedicated CSS classes (or GOV.UK utility classes) for maintainability and consistency.

e2e-tests/tests/subscription-list-types.spec.ts (3)

190-217: Third test duplicates validations already covered in the main journey test.

Test 1 already exercises the same validation errors for subscription-add-method (line 22–24), subscription-list-types (line 45–47), and subscription-list-language (line 76–78). This test adds no new coverage beyond what's already inline. Consider removing it or folding any unique assertions into the primary journey test. As per coding guidelines, E2E tests should "minimize test count with one test per complete user journey, including validations, Welsh translations, and accessibility checks inline rather than in separate tests."


83-87: Brittle keyboard navigation — hardcoded Tab count.

Two Tab presses assumes a specific DOM order between the "English" radio and the "Continue" button. If the page gains or loses a focusable element, this silently tests the wrong thing. Consider locating the button and calling .focus() directly, or using page.keyboard.press("Tab") in a loop until the expected element is focused.


4-9: Repeated sign-in block across all three tests.

The same sign-in sequence (goto, fill email, fill password, click) is copy-pasted in each test. The imports section references e2e-tests/utils/sso-helpers.ts — consider extracting a shared helper to reduce duplication.

Also applies to: 148-153, 190-195

libs/verified-pages/src/pages/subscription-by-location/index.ts (2)

209-209: Unsafe any cast for CSRF token.

(req as any).csrfToken?.() bypasses type safety. Consider extending the Express Request type or using a typed middleware interface so the CSRF token accessor is properly typed.


43-59: Triplicated query-param-to-number-array parsing.

The same pattern (check Array.isArray, map to Number, filter isFinite, else single-value) is repeated for jurisdictionParam, regionParam, and subJurisdictionParam. Extract a small helper like toNumberArray(param) to reduce duplication.

♻️ Example helper
+const toNumberArray = (param: unknown): number[] => {
+  if (Array.isArray(param)) {
+    return param.map(Number).filter(Number.isFinite);
+  }
+  if (param && Number.isFinite(Number(param))) {
+    return [Number(param)];
+  }
+  return [];
+};
+
 const getHandler = async (req: Request, res: Response) => {
   ...
-  const selectedJurisdictions = Array.isArray(jurisdictionParam)
-    ? jurisdictionParam.map(Number).filter(Number.isFinite)
-    : jurisdictionParam && Number.isFinite(Number(jurisdictionParam))
-      ? [Number(jurisdictionParam)]
-      : [];
+  const selectedJurisdictions = toNumberArray(jurisdictionParam);
+  const selectedRegions = toNumberArray(regionParam);
+  const selectedSubJurisdictions = toNumberArray(subJurisdictionParam);
libs/system-admin-pages/src/pages/find-users/index.ts (2)

99-105: roleLabels and provenanceLabels maps defined twice.

The same mappings appear at lines 100–105 and 134–139 (roles) and lines 117–122 and 141–146 (provenances). Hoist them above both usages to avoid duplication.

Also applies to: 134-139


158-165: Unbounded pagination items for large result sets.

Every page number from 1 to totalPages is emitted. With many users, this could generate hundreds of pagination links. GOV.UK pagination pattern recommends using ellipsis to truncate. Consider capping visible page numbers and inserting { ellipsis: true } entries.

libs/subscription-list-types/src/subscription-list-type/service.ts (1)

35-49: Redundant find-before-delete in deleteListTypeSubscription.

findListTypeSubscriptionById (line 36) fetches the record only to confirm it exists, then deleteListTypeSubscriptionRecord (line 42) deletes by the same criteria and independently checks count === 0. The initial find adds a round-trip without value. Deleting directly and checking the returned count is sufficient.

♻️ Simplified
 export async function deleteListTypeSubscription(userId: string, listTypeSubscriptionId: string) {
-  const subscription = await findListTypeSubscriptionById(listTypeSubscriptionId, userId);
-
-  if (!subscription) {
-    throw new Error("Subscription not found");
-  }
-
   const count = await deleteListTypeSubscriptionRecord(listTypeSubscriptionId, userId);
 
   if (count === 0) {
     throw new Error("Subscription not found");
   }
 
   return count;
 }
libs/verified-pages/src/pages/subscription-confirm/index.ts (1)

154-162: Fragile string-matching for error classification.

getUserFriendlyErrorMessage relies on substrings like "Maximum", "50", and "Already subscribed" in the error message. If the wording in service.ts changes, the mapping silently falls through to errorGeneric. Consider using typed error subclasses or error codes instead.

♻️ Sketch using error codes
// In service.ts
export class MaxSubscriptionsError extends Error {
  code = "MAX_SUBSCRIPTIONS" as const;
}
export class DuplicateSubscriptionError extends Error {
  code = "DUPLICATE_SUBSCRIPTION" as const;
}

// In subscription-confirm/index.ts
const getUserFriendlyErrorMessage = (error: Error, t: typeof en | typeof cy) => {
  if ("code" in error && error.code === "MAX_SUBSCRIPTIONS") return t.errorMaxSubscriptions;
  if ("code" in error && error.code === "DUPLICATE_SUBSCRIPTION") return t.errorDuplicateSubscription;
  return t.errorGeneric;
};

Comment on lines +12 to +28
export async function createListTypeSubscriptions(userId: string, listTypeIds: number[], language: string) {
const currentCount = await countListTypeSubscriptionsByUserId(userId);

if (currentCount + listTypeIds.length > MAX_LIST_TYPE_SUBSCRIPTIONS) {
throw new Error(`Maximum ${MAX_LIST_TYPE_SUBSCRIPTIONS} list type subscriptions allowed`);
}

for (const listTypeId of listTypeIds) {
const duplicate = await findDuplicateListTypeSubscription(userId, listTypeId, language);
if (duplicate) {
throw new Error(`Already subscribed to list type ${listTypeId} with language ${language}`);
}
}

const subscriptions = await Promise.all(listTypeIds.map((listTypeId) => createListTypeSubscriptionRecord(userId, listTypeId, language)));

return subscriptions;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

createListTypeSubscriptions is not atomic — partial creation and race conditions possible.

  1. Partial writes: Promise.all (line 26) fires all inserts concurrently. If one fails mid-way (e.g., DB constraint violation on a duplicate), the others may have already committed, leaving the user with a partial set of subscriptions.
  2. TOCTOU: The count check (line 13) and duplicate checks (lines 19–24) are not atomic with the subsequent creates. A concurrent request for the same user can bypass both guards.

Consider wrapping the entire operation in a Prisma $transaction to ensure all-or-nothing semantics.

🛡️ Sketch
 export async function createListTypeSubscriptions(userId: string, listTypeIds: number[], language: string) {
-  const currentCount = await countListTypeSubscriptionsByUserId(userId);
-
-  if (currentCount + listTypeIds.length > MAX_LIST_TYPE_SUBSCRIPTIONS) {
-    throw new Error(`Maximum ${MAX_LIST_TYPE_SUBSCRIPTIONS} list type subscriptions allowed`);
-  }
-
-  for (const listTypeId of listTypeIds) {
-    const duplicate = await findDuplicateListTypeSubscription(userId, listTypeId, language);
-    if (duplicate) {
-      throw new Error(`Already subscribed to list type ${listTypeId} with language ${language}`);
-    }
-  }
-
-  const subscriptions = await Promise.all(listTypeIds.map((listTypeId) => createListTypeSubscriptionRecord(userId, listTypeId, language)));
-
-  return subscriptions;
+  // Use a transaction for atomicity
+  return prisma.$transaction(async (tx) => {
+    const currentCount = await tx.subscriptionListType.count({ where: { userId } });
+    if (currentCount + listTypeIds.length > MAX_LIST_TYPE_SUBSCRIPTIONS) {
+      throw new Error(`Maximum ${MAX_LIST_TYPE_SUBSCRIPTIONS} list type subscriptions allowed`);
+    }
+    const subscriptions = [];
+    for (const listTypeId of listTypeIds) {
+      const duplicate = await tx.subscriptionListType.findFirst({ where: { userId, listTypeId, language } });
+      if (duplicate) {
+        throw new Error(`Already subscribed to list type ${listTypeId} with language ${language}`);
+      }
+      subscriptions.push(await tx.subscriptionListType.create({ data: { userId, listTypeId, language } }));
+    }
+    return subscriptions;
+  });
 }

Comment on lines +230 to +239
return res.render("find-users/index", {
...content,
users: searchResult.users,
totalCount: searchResult.totalCount,
currentPage: 1,
totalPages: searchResult.totalPages,
filters,
errors: validationErrors,
lng: language === "cy" ? "cy" : ""
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

POST validation-error render also omits paginationItems, paginationPrevious, paginationNext, selectedFilterGroups, and hasFilters.

The GET render call (lines 181–195) passes a rich view model with pagination and filter groups. The POST error path passes a minimal subset, so the template will have missing/undefined values for pagination controls and filter tags when re-rendering after a validation error.

Comment on lines +85 to +90
<form method="post">
{{ govukButton({
text: continueButton,
disabled: hasNoListTypes
}) }}
</form>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing CSRF token in the form.

The POST form lacks a CSRF hidden input, unlike other forms in the codebase (e.g., subscription-management/index.njk which includes <input type="hidden" name="_csrf" value="{{ csrfToken }}">).

Proposed fix
 <form method="post">
+  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
   {{ govukButton({
     text: continueButton,
     disabled: hasNoListTypes
   }) }}
 </form>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<form method="post">
{{ govukButton({
text: continueButton,
disabled: hasNoListTypes
}) }}
</form>
<form method="post">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
{{ govukButton({
text: continueButton,
disabled: hasNoListTypes
}) }}
</form>

Comment on lines +99 to +127
// Build table rows
const locationRows = locations.map((location) => [
{ text: location.name },
{
html: `<a href="/subscription-confirm?removeLocation=${location.locationId}" class="govuk-link">${t.removeLink}<span class="govuk-visually-hidden"> ${location.name}</span></a>`,
classes: "govuk-table__cell--numeric"
}
]);

const listTypeRows = selectedListTypes.map((listType) => {
const listTypeName = locale === "cy" ? listType.welshFriendlyName : listType.englishFriendlyName;
return [
{ text: listTypeName },
{
html: `<a href="/subscription-confirm?removeListType=${listType.id}" class="govuk-link">${t.removeLink}<span class="govuk-visually-hidden"> ${listTypeName}</span></a>`,
classes: "govuk-table__cell--numeric"
}
];
});

const languageRows = [
[
{ text: languageDisplay[req.session.listTypeSubscription.language as keyof typeof languageDisplay] || "" },
{
html: `<a href="/subscription-list-language" class="govuk-link">${t.changeLink}<span class="govuk-visually-hidden"> ${t.languageHeading}</span></a>`,
classes: "govuk-table__cell--numeric"
}
]
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

~60 lines of row-building logic duplicated between GET and POST error paths.

locationRows, listTypeRows, languageRows, and languageDisplay are constructed identically in the GET handler (lines 93–127) and the POST error handler (lines 229–263). Extract a shared helper function to build the view model.

Also applies to: 229-263

Comment on lines +25 to +51
<form method="post" novalidate>
{{ govukRadios({
name: "language",
items: [
{
value: "ENGLISH",
text: radioOptionEnglish,
checked: data.language == "ENGLISH"
},
{
value: "WELSH",
text: radioOptionWelsh,
checked: data.language == "WELSH"
},
{
value: "BOTH",
text: radioOptionBoth,
checked: data.language == "BOTH"
}
],
errorMessage: errors[0] if errors else undefined
}) }}

{{ govukButton({
text: continueButton
}) }}
</form>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing CSRF token in the POST form.

Other forms in this PR (e.g., subscription-by-location/index.njk line 133) include <input type="hidden" name="_csrf" value="{{ csrfToken }}">. This form does not, which will cause POST requests to fail if CSRF protection middleware is active.

Proposed fix
     <form method="post" novalidate>
+      <input type="hidden" name="_csrf" value="{{ csrfToken }}">
       {{ govukRadios({
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<form method="post" novalidate>
{{ govukRadios({
name: "language",
items: [
{
value: "ENGLISH",
text: radioOptionEnglish,
checked: data.language == "ENGLISH"
},
{
value: "WELSH",
text: radioOptionWelsh,
checked: data.language == "WELSH"
},
{
value: "BOTH",
text: radioOptionBoth,
checked: data.language == "BOTH"
}
],
errorMessage: errors[0] if errors else undefined
}) }}
{{ govukButton({
text: continueButton
}) }}
</form>
<form method="post" novalidate>
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
{{ govukRadios({
name: "language",
items: [
{
value: "ENGLISH",
text: radioOptionEnglish,
checked: data.language == "ENGLISH"
},
{
value: "WELSH",
text: radioOptionWelsh,
checked: data.language == "WELSH"
},
{
value: "BOTH",
text: radioOptionBoth,
checked: data.language == "BOTH"
}
],
errorMessage: errors[0] if errors else undefined
}) }}
{{ govukButton({
text: continueButton
}) }}
</form>

Comment on lines +6 to +15
interface ListTypeSubscriptionSession {
selectedListTypeIds?: number[];
language?: string;
}

declare module "express-session" {
interface SessionData {
listTypeSubscription?: ListTypeSubscriptionSession;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Duplicate ListTypeSubscriptionSession interface and session augmentation.

This interface and declare module block are identical to those in libs/verified-pages/src/pages/subscription-list-types/index.ts (lines 7–16). Extract to a shared location (e.g., a types file in the subscription-list-types package) and import from both controllers.

#!/bin/bash
# Verify duplication across the codebase
rg -n "ListTypeSubscriptionSession" --type=ts

Comment on lines +7 to +12
{% block backLink %}
{{ govukBackLink({
text: backLinkText,
href: "/location-name-search"
}) }}
{% endblock %}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Back link points to /location-name-search — likely incorrect.

This page is for list-type subscriptions, not location-based ones. The back link should probably navigate to /subscription-add-method (or the previous step in the list-type flow).

Comment on lines +13 to +17
declare module "express-session" {
interface SessionData {
listTypeSubscription?: ListTypeSubscriptionSession;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n "listTypeSubscription" --type=ts -C3

Repository: hmcts/cath-service

Length of output: 50374


🏁 Script executed:

rg -n 'declare module.*express-session' --type=ts -A5

Repository: hmcts/cath-service

Length of output: 6059


Consolidate the session module augmentation to a single location.

The listTypeSubscription augmentation is duplicated across at least five files: subscription-locations-review/index.ts, subscription-list-types/index.ts, subscription-list-language/index.ts, subscription-by-location/index.ts, and subscription-confirm/index.ts. Move this declaration to a shared session types file to prevent maintenance issues and ensure consistency across the flow.

Comment on lines 140 to 159
.filter-section-heading {
font-family: "GDS Transport", arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 19px;
font-weight: 700;
line-height: 1.3157894737;
color: #1d70b8;
text-decoration: underline;
margin-bottom: 0;
display: block;
}

.filter-section-heading {
font-weight: 600;
.filter-section-toggle:hover .filter-section-heading {
color: #003078;
}

.filter-section-toggle:hover .filter-section-icon {
color: #003078;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing :focus styles on .filter-section-toggle for keyboard navigation.

The PR objectives explicitly require WCAG 2.2 AA compliance with keyboard-accessible controls. The .filter-section-toggle button has hover states for its children but no visible :focus indicator. Other interactive elements in this file (e.g., .az-navigation__link:focus at Line 110) already define focus styles.

Consider adding a focus rule consistent with the GOV.UK focus pattern used elsewhere in this file:

Proposed fix
+.filter-section-toggle:focus {
+  outline: 3px solid transparent;
+  color: `#0b0c0c`;
+  background-color: `#fd0`;
+  box-shadow: 0 -2px `#fd0`, 0 4px `#0b0c0c`;
+}
+
+.filter-section-toggle:focus .filter-section-heading {
+  color: `#0b0c0c`;
+  text-decoration: none;
+}
+
 .filter-section-toggle:hover .filter-section-heading {
   color: `#003078`;
 }

tsconfig.json Outdated
"@hmcts/blob-ingestion": ["libs/api/src"],
"@hmcts/blob-ingestion/config": ["libs/api/src/config"],
"@hmcts/subscriptions": ["libs/subscriptions/src"],
"@hmcts/subscription-list-types": ["libs/subscription-list-types/src"],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if any file imports from `@hmcts/subscription-list-types/config`
rg -n '@hmcts/subscription-list-types/config'

Repository: hmcts/cath-service

Length of output: 204


🏁 Script executed:

# Check the package.json for `@hmcts/subscription-list-types`
find . -path "*/subscription-list-types/package.json" -type f | head -5

Repository: hmcts/cath-service

Length of output: 105


🏁 Script executed:

# Check current tsconfig.json paths around the `@hmcts/subscription-list-types` entry
cat -n tsconfig.json | sed -n '25,40p'

Repository: hmcts/cath-service

Length of output: 1074


🏁 Script executed:

# Check what exports are defined in subscription-list-types package.json
if [ -f "libs/subscription-list-types/package.json" ]; then
  jq '.exports' libs/subscription-list-types/package.json
fi

Repository: hmcts/cath-service

Length of output: 239


Add the missing /config path alias to tsconfig.json.

Code in apps/postgres/src/schema-discovery.ts imports from @hmcts/subscription-list-types/config, but the corresponding path alias is missing from tsconfig.json. Add "@hmcts/subscription-list-types/config": ["libs/subscription-list-types/src/config"] to the paths object, consistent with other packages like @hmcts/blob-ingestion/config and the package's own exports configuration.

@alexbottenberg alexbottenberg force-pushed the feature/310-user-management-implementation branch from c4f1564 to b7f5fcb Compare February 9, 2026 11:50
alexbottenberg and others added 2 commits February 11, 2026 12:37
Replaces Date.now() with randomUUID() to prevent test collisions when running in parallel.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Update axios from 1.13.2 to 1.13.5 to address CVE with CVSS 7.5.
Added resolution in package.json to force the fixed version.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
alexbottenberg and others added 9 commits February 16, 2026 12:08
Resolved conflicts:
- e2e-tests/tests/system-admin-dashboard.spec.ts: Kept feature branch version with consolidated E2E test pattern
- package.json: Merged security resolutions from both branches (qs 6.14.2, vite 7.3.1, body-parser 2.2.2, node-forge 1.3.3)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Updated User Management dashboard link from /find-users to /user-management
- Updated Location Metadata dashboard link from /location-metadata to /location-metadata-search
- Merged dependency resolutions: updated qs (6.14.2), tar (7.5.9), added node-forge (1.3.3) and vite (7.3.1)
- Regenerated yarn.lock for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…om:hmcts/cath-service into feature/310-user-management-implementation
Update ajv from 8.17.1 to 8.18.0 to address CVE with CVSS score 5.5.

Updated in:
- libs/list-types/common/package.json
- libs/publication/package.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Moved back link from page_content block to backLink block to properly
override the base template's default back link, preventing duplication.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…aracters

Updated validation regex to allow hyphens, underscores, @ symbols, and periods
which are commonly found in User Provenance IDs. Also increased the max length
from 50 to 100 characters.

Changes:
- Added PROVENANCE_ID_REGEX to allow alphanumeric plus -_@.
- Updated validateUserProvenanceId to use new regex
- Increased max length from 50 to 100 characters
- Updated error message to reflect allowed characters
- Added comprehensive tests for valid and invalid characters
- All 35 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
… update date format

Changes:
- English: Changed 'Last sign in' to 'Last verified'
- English: Changed 'Never signed in' to 'Never verified'
- Welsh: Changed 'Mewngofnodi diwethaf' to 'Dilyswyd diwethaf'
- Welsh: Changed 'Heb fewngofnodi erioed' to 'Heb ei ddilysu erioed'
- Updated date format from DD/MM/YYYY to DD/MM/YYYY HH:MM:SS for both Creation Date and Last verified
- Custom formatDate function now includes hours, minutes, and seconds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…-users

The test was expecting href="/user-management" but the actual route is
/find-users (based on the page directory name). Updated the test to match
the actual implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
alexbottenberg and others added 4 commits February 24, 2026 11:36
Resolve conflict in system-admin-dashboard.spec.ts: keep consolidated
single-test approach with updated tile data (9 tiles including new
Manage List Types tile and corrected hrefs from master).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Update seed data provenance from B2C to B2C_IDAM
- Update validation, filter checkboxes, and query logic to use B2C_IDAM
- Keep display label as "B2C" across filter tags, user table, and manage-user page
- Fix filter tags overflowing container by adding word wrapping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
alexbottenberg and others added 2 commits March 10, 2026 10:18
Changed terminology on the manage user page to use 'sign in' instead of 'verified' for better clarity:
- 'Last verified' → 'Last sign in'
- 'Never verified' → 'Never signed in'

Updated both English and Welsh translations.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[VIBE-362] User Management

2 participants