Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
0908556
docs(301): add technical plan for third-party user management
github-actions[bot] Mar 18, 2026
78e2420
feat(301): implement third-party user management
alexbottenberg Mar 19, 2026
4280e12
fix: make confirmation panels full width and fix radio button visibility
alexbottenberg Mar 19, 2026
49a3c65
test: add missing tests for third-party subscriptions page
alexbottenberg Mar 19, 2026
920dadd
fix: ensure dotenv loads before app module imports in both servers
alexbottenberg Mar 20, 2026
ed19716
test: add third-party-user schema to schema discovery test
alexbottenberg Mar 20, 2026
4d2071e
fix: use govukBackLink macro in third-party-users create summary page
alexbottenberg Mar 20, 2026
fc8906d
fix: remove unused translation object in postHandler
alexbottenberg Mar 20, 2026
cdd7232
fix: add missing dev and format scripts to third-party-user package
alexbottenberg Mar 20, 2026
981ca0b
fix: add missing assets exclusion to third-party-user tsconfig
alexbottenberg Mar 20, 2026
9a6fec3
fix: remove --env-file flag from web dev scripts to fix CI e2e startup
alexbottenberg Mar 20, 2026
2acebb2
fix: E2E tests for third-party user management
ashwini-mv Apr 29, 2026
fc11784
refactor: rename delete/confirmation to delete/success
abottenberg May 14, 2026
e033c4d
refactor: move manage user page into [id]/manage subfolder
abottenberg May 14, 2026
846bd82
refactor: move delete page into [id]/delete/confirmation subfolder
abottenberg May 14, 2026
666375f
refactor: rename subscriptions/confirmation to subscriptions/success
abottenberg May 14, 2026
29134ef
refactor: move subscriptions page into [id]/subscriptions/manage subf…
abottenberg May 14, 2026
c986810
chore: remove old files left over from folder restructures
abottenberg May 14, 2026
250d1a1
fix: rename list_type to list_type_id as FK to list_types table
abottenberg May 14, 2026
9ac8fa7
fix: use listTypeId integer FK when persisting third party subscriptions
abottenberg May 14, 2026
d5e7ab9
feat: add pip-ss-kv keyvault to preview template for identity secrets
abottenberg May 14, 2026
e010956
chore: update yarn.lock
abottenberg May 14, 2026
7a2395e
chore: fix formatting in third-party-users [id] test
abottenberg May 14, 2026
16bb104
fix: resolve listType to listTypeId in subscription audit log
abottenberg May 14, 2026
0d2e998
fix: add getListTypeId function and use it in third-party-user service
abottenberg May 14, 2026
812d6f4
fix: skip Key Vault fetch when credentials already in environment
abottenberg May 14, 2026
850964f
fix: correct expected deleted count in cleanup test
abottenberg May 15, 2026
a7e0cda
revert: remove keyvault config from preview template
abottenberg May 15, 2026
67ab76e
feat: add OAuth credentials configuration for third-party users
abottenberg May 18, 2026
c61ada1
fix: support legacy third-party users in OAuth config journey
abottenberg May 19, 2026
2bf33a0
chore: add migration to drop third_party_subscription list_type fkey …
abottenberg May 19, 2026
03bc637
feat: add OAuth credentials configuration for third-party users
abottenberg May 22, 2026
2c2e830
fix: correct navigation links on OAuth config pages
abottenberg May 22, 2026
dfb7362
fix: remove extra blank line in server.ts to pass lint
abottenberg May 22, 2026
9a7a9ba
fix: increase notification polling timeout for Puppeteer PDF generati…
abottenberg May 22, 2026
0ff5a4d
fix: remove extra blank line in server.ts to pass lint
abottenberg May 26, 2026
13b1418
fix: resolve Biome formatter errors in system-admin-pages
abottenberg May 26, 2026
412a962
fix: correct postgresql setup.databases to use map type
abottenberg May 26, 2026
a48c13b
fix: rewrite manage-third-party-subscriptions page with per-list-type…
abottenberg May 26, 2026
aeb89df
Merge origin/master into feature/301-third-party-user-management
abottenberg May 27, 2026
285f494
fix: resolve Biome lint errors in manage-third-party-subscriptions
abottenberg May 27, 2026
a9a5344
fix: regenerate yarn.lock after master merge
abottenberg May 27, 2026
0f4fb09
fix: add third-party-user schema to consolidated postgres-prisma sche…
abottenberg May 27, 2026
652e830
fix: revert postgresql setup.databases to list format
abottenberg May 27, 2026
ea21975
fix: correct postgresql setup.databases map format with explicit name…
abottenberg May 27, 2026
7dd82af
revert: restore postgresql setup.databases to list format matching ma…
abottenberg May 28, 2026
20e9129
fix: pre-populate oauth-config form with session values when returnin…
abottenberg May 28, 2026
38af452
Merge master into feature/301-third-party-user-management
junaidiqbalmoj Jun 16, 2026
e0f45f0
refactor: apply new controller and template patterns across third-par…
junaidiqbalmoj Jun 16, 2026
a6ce0c3
fix: update launchdarkly lockfile entries to latest patch versions
junaidiqbalmoj Jun 16, 2026
569a14b
fix: update third-party-user tests and controllers for res.locals loc…
junaidiqbalmoj Jun 16, 2026
74fcc08
Merge branch 'master' into feature/301-third-party-user-management
junaidiqbalmoj Jun 17, 2026
ff4fbac
Register system-admin-pages lib routes to fix 404 on /third-party-use…
junaidiqbalmoj Jun 17, 2026
e608e51
Add Manage Third-Party Subscribers tile and rename pages from /third-…
junaidiqbalmoj Jun 17, 2026
51de853
feat: reimplement manage-third-party-subscriptions with global sensit…
junaidiqbalmoj Jun 17, 2026
007d3bb
feat: prevent duplicate third party user names on creation
junaidiqbalmoj Jun 17, 2026
f52d223
fix: remove duplicate dashboard tile pointing to /third-party-users
junaidiqbalmoj Jun 17, 2026
7f225d8
fix: update third-party-subscribers pages to use subscriber terminology
junaidiqbalmoj Jun 17, 2026
0ee8696
fix: manage subscriber page layout and duplicate check on create
junaidiqbalmoj Jun 17, 2026
6e89bc6
fix: widen manage subscriber column so all three buttons fit on one line
junaidiqbalmoj Jun 17, 2026
d8f4b98
fix: remove client secret row from OAuth config summary page
junaidiqbalmoj Jun 17, 2026
364a66c
fix: remove trailing comma and blank line causing Nunjucks parse erro…
junaidiqbalmoj Jun 17, 2026
b8ea8d1
fix: remove cancel button from OAuth config page
junaidiqbalmoj Jun 17, 2026
b034f6d
feat: delete Key Vault secrets when deleting a third-party subscriber
junaidiqbalmoj Jun 17, 2026
485867e
chore: skip third-party user management E2E tests until system admin …
junaidiqbalmoj Jun 17, 2026
0c74664
fix: resolve test failures and lint issues for third-party subscriber…
junaidiqbalmoj Jun 17, 2026
52bed4d
chore: merge master into feature/301-third-party-user-management
junaidiqbalmoj Jun 17, 2026
3cb7cf9
fix: regenerate yarn.lock after merge conflict resolution
junaidiqbalmoj Jun 17, 2026
0d6019f
fix: resolve CI build and test failures
junaidiqbalmoj Jun 17, 2026
c9f5bf2
fix: resolve TypeScript TS2345/TS18048 errors in third-party-subscrib…
junaidiqbalmoj Jun 17, 2026
5747975
fix: remove dotenv dependency from server.ts files
junaidiqbalmoj Jun 17, 2026
4f14502
chore: merge master into feature/301-third-party-user-management
junaidiqbalmoj Jun 18, 2026
b964f42
fix: correct audit action mock and lint formatting in subscriptions test
junaidiqbalmoj Jun 18, 2026
f643ecc
chore: merge master into feature/301-third-party-user-management
junaidiqbalmoj Jul 1, 2026
0e8edb2
feat(301): migrate third party user/subscription IDs to UUID and fix …
junaidiqbalmoj Jul 1, 2026
aa1c793
chore: resolve merge conflicts with master and regenerate Prisma client
junaidiqbalmoj Jul 3, 2026
078abca
chore: stage remaining merge changes from master
junaidiqbalmoj Jul 3, 2026
4971798
revert: remove unintended changes to run.sh, CLAUDE.md, and sonar-pro…
junaidiqbalmoj Jul 3, 2026
c104e12
chore: resolve merge conflicts with master and regenerate Prisma client
junaidiqbalmoj Jul 3, 2026
c4d70f6
update delete success width
alao-daniel Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 54 additions & 18 deletions .claude/hooks/post-write.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,18 @@

set -euo pipefail

# Get the project directory (hooks run in project context)
PROJECT_DIR="$(pwd)"
# Get the project directory - use CLAUDE_PROJECT_DIR if set (hooks run in worktree context),
# otherwise fall back to pwd
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"

# Resolve the main worktree (where node_modules lives)
COMMON_DIR="$(git -C "$PROJECT_DIR" rev-parse --git-common-dir 2>/dev/null || echo "$PROJECT_DIR/.git")"
MAIN_WORKTREE="$(dirname "$COMMON_DIR")"
if [ -d "$MAIN_WORKTREE/node_modules" ]; then
BIOME_BIN="$MAIN_WORKTREE/node_modules/.bin/biome"
else
BIOME_BIN="$PROJECT_DIR/node_modules/.bin/biome"
fi

# Logging function
log_hook() {
Expand All @@ -14,23 +24,49 @@ log_hook() {
}

log_hook "Hook started"
echo "🔧 Running post-write checks..."

# Run formatter and linter directly via root biome (not turbo per-workspace)
echo "Checking code formatting and linting..."
log_hook "Starting biome format and lint"
if ! yarn biome format --write .; then
echo "❌ Code formatting check failed. Run 'yarn format' to fix."
log_hook "Formatter check failed"
exit 2

if [ ! -x "$BIOME_BIN" ]; then
log_hook "biome not found at $BIOME_BIN, skipping"
exit 0
fi

# Only check files that were actually written (passed via CLAUDE_FILE_PATHS env var)
# Fall back to checking nothing if not set — don't scan the whole codebase
FILES_TO_CHECK="${CLAUDE_FILE_PATHS:-}"

if [ -z "$FILES_TO_CHECK" ]; then
log_hook "No CLAUDE_FILE_PATHS set, skipping"
exit 0
fi

# Filter to only TypeScript/JavaScript files (skip .njk, .json, .prisma, .sh, etc.)
TS_FILES=""
while IFS= read -r f; do
case "$f" in
*.ts|*.tsx|*.js|*.jsx)
if [ -f "$f" ]; then
TS_FILES="$TS_FILES $f"
fi
;;
esac
done <<< "$FILES_TO_CHECK"

if [ -z "$TS_FILES" ]; then
log_hook "No TS/JS files to check"
exit 0
fi
Comment on lines +42 to +57

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

Paths with spaces will break due to word-splitting.

The $TS_FILES variable is built by concatenation and later expanded unquoted (lines 63, 67). Filenames containing spaces will be incorrectly split.

🛠️ Suggested fix using an array
-# Filter to only TypeScript/JavaScript files (skip .njk, .json, .prisma, .sh, etc.)
-TS_FILES=""
-while IFS= read -r f; do
-    case "$f" in
-        *.ts|*.tsx|*.js|*.jsx)
-            if [ -f "$f" ]; then
-                TS_FILES="$TS_FILES $f"
-            fi
-            ;;
-    esac
-done <<< "$FILES_TO_CHECK"
-
-if [ -z "$TS_FILES" ]; then
-    log_hook "No TS/JS files to check"
-    exit 0
-fi
+# Filter to only TypeScript/JavaScript files (skip .njk, .json, .prisma, .sh, etc.)
+TS_FILES=()
+while IFS= read -r f; do
+    case "$f" in
+        *.ts|*.tsx|*.js|*.jsx)
+            if [ -f "$f" ]; then
+                TS_FILES+=("$f")
+            fi
+            ;;
+    esac
+done <<< "$FILES_TO_CHECK"
+
+if [ ${`#TS_FILES`[@]} -eq 0 ]; then
+    log_hook "No TS/JS files to check"
+    exit 0
+fi

Then update the biome invocations:

-if ! $BIOME_BIN format --write $TS_FILES 2>&1; then
+if ! "$BIOME_BIN" format --write "${TS_FILES[@]}" 2>&1; then
     log_hook "Format had issues (non-blocking)"
 fi

-if ! $BIOME_BIN check --write $TS_FILES 2>&1; then
+if ! "$BIOME_BIN" check --write "${TS_FILES[@]}" 2>&1; then
     log_hook "Lint had issues (non-blocking)"
 fi


log_hook "Checking files: $TS_FILES"

# Format then lint only the written files
# Use || true so pre-existing errors elsewhere don't block this write
if ! $BIOME_BIN format --write $TS_FILES 2>&1; then
log_hook "Format had issues (non-blocking)"
fi

if ! yarn biome check --write .; then
echo "❌ Linting failed"
log_hook "Linter failed"
exit 2
if ! $BIOME_BIN check --write $TS_FILES 2>&1; then
log_hook "Lint had issues (non-blocking)"
fi

echo "✅ Post-write checks completed"
log_hook "Hook completed successfully"
exit 0
log_hook "Hook completed"
exit 0
2 changes: 1 addition & 1 deletion apps/api/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createApp } from "./app.js";
const { createApp } = await import("./app.js");

const PORT = process.env.API_PORT || 3001;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- CreateTable

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.

oauth confirmation table is missing completly.

CREATE TABLE "third_party_user" (
"id" TEXT NOT NULL,
"name" VARCHAR(255) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "third_party_user_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "third_party_subscription" (
"id" TEXT NOT NULL,
"third_party_user_id" TEXT NOT NULL,
"list_type_id" INTEGER NOT NULL,
"sensitivity" VARCHAR(20) NOT NULL,

CONSTRAINT "third_party_subscription_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "third_party_subscription_third_party_user_id_list_type_id_key" ON "third_party_subscription"("third_party_user_id", "list_type_id");

-- AddForeignKey
ALTER TABLE "third_party_subscription" ADD CONSTRAINT "third_party_subscription_third_party_user_id_fkey" FOREIGN KEY ("third_party_user_id") REFERENCES "third_party_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "third_party_subscription" ADD CONSTRAINT "third_party_subscription_list_type_id_fkey" FOREIGN KEY ("list_type_id") REFERENCES "list_types"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
2 changes: 2 additions & 0 deletions apps/postgres/prisma/migrations/20260518135301/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- DropForeignKey
ALTER TABLE "third_party_subscription" DROP CONSTRAINT "third_party_subscription_list_type_id_fkey";
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "third_party_secret" (
"name" VARCHAR(255) NOT NULL,
"value" VARCHAR(1000) NOT NULL,

CONSTRAINT "third_party_secret_pkey" PRIMARY KEY ("name")
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- Change third_party_user and third_party_subscription IDs from TEXT (CUID) to UUID
-- Existing rows are cleared as they have non-UUID IDs that cannot be migrated
TRUNCATE TABLE "third_party_subscription", "third_party_user" CASCADE;

ALTER TABLE "third_party_subscription" DROP CONSTRAINT IF EXISTS "third_party_subscription_third_party_user_id_fkey";
ALTER TABLE "third_party_subscription" DROP CONSTRAINT IF EXISTS "third_party_subscription_list_type_id_fkey";

ALTER TABLE "third_party_user"
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE UUID USING gen_random_uuid(),
ALTER COLUMN "id" SET DEFAULT gen_random_uuid();

ALTER TABLE "third_party_subscription"
ALTER COLUMN "id" DROP DEFAULT,
ALTER COLUMN "id" SET DATA TYPE UUID USING gen_random_uuid(),
ALTER COLUMN "id" SET DEFAULT gen_random_uuid(),
ALTER COLUMN "third_party_user_id" SET DATA TYPE UUID USING gen_random_uuid();

ALTER TABLE "third_party_subscription" ADD CONSTRAINT "third_party_subscription_third_party_user_id_fkey"
FOREIGN KEY ("third_party_user_id") REFERENCES "third_party_user"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "third_party_subscription" ADD CONSTRAINT "third_party_subscription_list_type_id_fkey"
FOREIGN KEY ("list_type_id") REFERENCES "list_types"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE "third_party_subscription" DROP CONSTRAINT "third_party_subscription_list_type_id_fkey";

-- AlterTable
ALTER TABLE "third_party_subscription" ALTER COLUMN "id" DROP DEFAULT;

-- AlterTable
ALTER TABLE "third_party_user" ALTER COLUMN "id" DROP DEFAULT;
5 changes: 5 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ MEDIA_START_PAGE_LINK=
# AZURE_B2C_TENANT_ID, AZURE_B2C_CLIENT_ID, AZURE_B2C_CLIENT_SECRET, AZURE_B2C_DOMAIN loaded from Key Vault
# See helm/values.yaml for Key Vault secret mappings

# Third Party OAuth Configuration
# Name of the Azure Key Vault storing third-party OAuth credentials (set in deployed environments)
# THIRD_PARTY_KEY_VAULT=<vault-name>
# MANAGED_IDENTITY_CLIENT_ID=

# Service URL for generating links in notifications
CATH_SERVICE_URL=https://www.court-tribunal-hearings.service.gov.uk

Expand Down
1 change: 1 addition & 0 deletions apps/web/helm/values.dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ nodejs:
image: 'hmctspublic.azurecr.io/cath/cath-web:latest'
environment:
REDIS_HOST: 'cath-{{ .Values.global.environment }}.redis.cache.windows.net'
THIRD_PARTY_KEY_VAULT: 'cath-ss-tp-kv-stg'
SSO_ALLOW_HTTP_REDIRECT: 'false'
CFT_IDAM_URL: 'https://idam-web-public.aat.platform.hmcts.net'
MEDIA_PASSWORD_RESET_LINK: 'https://sign-in.pip-frontend.staging.platform.hmcts.net/pip-frontend.staging.platform.hmcts.net/oauth2/v2.0/authorize?p=B2C_1A_PASSWORD_RESET&client_id=cae650ba-431b-4fc8-be14-22d476ebd31b&nonce=defaultNonce&redirect_uri=https://pip-frontend.staging.platform.hmcts.net/password-change-confirmation&scope=openid&response_type=code&prompt=login&response_mode=query'
Expand Down
1 change: 1 addition & 0 deletions apps/web/helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ nodejs:
ENVIRONMENT: '{{ .Values.global.environment }}'
POSTGRES_DATABASE: cath
REDIS_HOST: 'cath-redis-{{ .Values.global.environment }}.redis.cache.windows.net'
THIRD_PARTY_KEY_VAULT: 'cath-ss-tp-kv-{{ .Values.global.environment }}'
SSO_ALLOW_HTTP_REDIRECT: 'false'
CFT_IDAM_URL: https://idam-web-public.{{ .Values.global.environment }}.platform.hmcts.net
CATH_SERVICE_URL: https://cath-web.{{ .Values.global.environment }}.platform.hmcts.net
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "concurrently -r \"yarn:dev:watch\" \"yarn:build:dev:watch\"",
"dev:watch": "mkdir -p dist/assets; nodemon -w yarn.lock -w src -w ../../libs -e 'json,ts' -x 'tsx --env-file-if-exists=../../.env src/server.ts'",
"dev:watch": "mkdir -p dist/assets; nodemon -w yarn.lock -w src -w ../../libs -e 'json,ts' -x \"tsx --env-file-if-exists=../../.env src/server.ts\"",
"dev:nowatch": "yarn build:dev && tsx --env-file-if-exists=../../.env src/server.ts",
"build:dev:watch": "yarn build:dev --watch",
"build:dev": "NODE_OPTIONS='--import tsx' vite build --config vite.build.ts",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,8 @@ vi.mock("@hmcts/public-pages/config", () => ({

vi.mock("@hmcts/system-admin-pages/config", () => ({
fileUploadRoutes: ["/reference-data-upload"],
moduleRoot: "/mock/system-admin"
moduleRoot: "/mock/system-admin",
pages: { path: "/mock/system-admin/pages" }
}));

vi.mock("@hmcts/web-core/config", () => ({
Expand Down
8 changes: 6 additions & 2 deletions apps/web/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ import { moduleRoot as siacPoacPaacModuleRoot } from "@hmcts/siac-poac-paac-week
import { moduleRoot as sjpPressListModuleRoot } from "@hmcts/sjp-press-list/config";
import { moduleRoot as sjpPublicListModuleRoot } from "@hmcts/sjp-public-list/config";
import { moduleRoot as sscsDailyHearingListModuleRoot } from "@hmcts/sscs-daily-hearing-list/config";
import { fileUploadRoutes as systemAdminFileUploadRoutes, moduleRoot as systemAdminModuleRoot } from "@hmcts/system-admin-pages/config";
import {
fileUploadRoutes as systemAdminFileUploadRoutes,
moduleRoot as systemAdminModuleRoot,
pages as systemAdminPages
} from "@hmcts/system-admin-pages/config";
import { moduleRoot as utiacJrModuleRoot } from "@hmcts/utiac-jr-daily-hearing-list/config";
import { moduleRoot as utiacStatutoryAppealModuleRoot } from "@hmcts/utiac-statutory-appeal-daily-hearing-list/config";
import {
Expand Down Expand Up @@ -185,7 +189,7 @@ export async function createApp(): Promise<Express> {
app.use(await createSimpleRouter(publicPagesApiRoutes));

// Register all pages from apps/web/src/pages (includes route groups and admin)
app.use(await createSimpleRouter({ path: `${__dirname}/pages` }));
app.use(await createSimpleRouter({ path: `${__dirname}/pages` }, systemAdminPages));

// Enable test-support routes in non-production environments or when explicitly enabled
if (process.env.NODE_ENV !== "production" || process.env.ENABLE_TEST_SUPPORT === "true") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe("create-third-party-user-summary page", () => {
(req.session as any).createThirdPartyUser = {
name: "Test User",
idempotencyToken: "abc123",
createdUserId: "user-123"
createdUserId: "00000000-0000-0000-0000-000000000001"
};

const handler = POST[POST.length - 1];
Expand All @@ -122,7 +122,7 @@ describe("create-third-party-user-summary page", () => {
(req.session as any).createThirdPartyUser = {
name: "Test User",
idempotencyToken: "abc123",
createdUserId: "user-123"
createdUserId: "00000000-0000-0000-0000-000000000001"
};

const handler = POST[POST.length - 1];
Expand All @@ -136,13 +136,13 @@ describe("create-third-party-user-summary page", () => {
name: "Test User",
idempotencyToken: "abc123"
};
(createThirdPartyUser as any).mockResolvedValue({ id: "new-user-id", name: "Test User" });
(createThirdPartyUser as any).mockResolvedValue({ id: "00000000-0000-0000-0000-000000000002", name: "Test User" });

const handler = POST[POST.length - 1];
await handler(req as Request, res as Response, vi.fn());

expect(createThirdPartyUser).toHaveBeenCalledWith("Test User");
expect((req.session as any).createThirdPartyUser.createdUserId).toBe("new-user-id");
expect((req.session as any).createThirdPartyUser.createdUserId).toBe("00000000-0000-0000-0000-000000000002");
expect(res.redirect).toHaveBeenCalledWith("/third-party-user-created");
});

Expand All @@ -152,7 +152,7 @@ describe("create-third-party-user-summary page", () => {
name: "Test User",
idempotencyToken: "abc123"
};
(createThirdPartyUser as any).mockResolvedValue({ id: "new-user-id", name: "Test User" });
(createThirdPartyUser as any).mockResolvedValue({ id: "00000000-0000-0000-0000-000000000002", name: "Test User" });

const handler = POST[POST.length - 1];
await handler(req as Request, res as Response, vi.fn());
Expand All @@ -165,15 +165,15 @@ describe("create-third-party-user-summary page", () => {
name: "Test User",
idempotencyToken: "abc123"
};
(createThirdPartyUser as any).mockResolvedValue({ id: "new-user-id", name: "Test User" });
(createThirdPartyUser as any).mockResolvedValue({ id: "00000000-0000-0000-0000-000000000002", name: "Test User" });

const handler = POST[POST.length - 1];
await handler(req as Request, res as Response, vi.fn());

expect(req.auditMetadata).toEqual({
shouldLog: true,
action: "Create third party user",
entityInfo: "ID: new-user-id, Name: Test User"
entityInfo: "ID: 00000000-0000-0000-0000-000000000002, Name: Test User"
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export const cy = {
continueButtonText: "Parhau",
errorSummaryTitle: "Mae yna broblem",
nameRequired: "Rhowch enw",
nameTooLong: "Rhaid i'r enw fod yn 255 nod neu lai"
nameTooLong: "Rhaid i'r enw fod yn 255 nod neu lai",
nameDuplicate: "Mae defnyddiwr trydydd parti gyda'r enw hwn eisoes yn bodoli"
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export const en = {
continueButtonText: "Continue",
errorSummaryTitle: "There is a problem",
nameRequired: "Enter a name",
nameTooLong: "Name must be 255 characters or less"
nameTooLong: "Name must be 255 characters or less",
nameDuplicate: "A third party user with this name already exists"
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ vi.mock("@hmcts/system-admin-pages", async (importOriginal) => {
const actual = await importOriginal<typeof import("@hmcts/system-admin-pages")>();
return {
...actual,
findThirdPartyUserByName: vi.fn(),
validateThirdPartyUserName: vi.fn()
};
});

import { validateThirdPartyUserName } from "@hmcts/system-admin-pages";
import { findThirdPartyUserByName, validateThirdPartyUserName } from "@hmcts/system-admin-pages";

describe("create-third-party-user page", () => {
let req: Partial<Request>;
let res: Partial<Response>;

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(findThirdPartyUserByName).mockResolvedValue(null);

req = {
query: {},
Expand Down Expand Up @@ -161,5 +163,39 @@ describe("create-third-party-user page", () => {

expect((req.session as any).createThirdPartyUser.idempotencyToken).toBeDefined();
});

it("should show duplicate name error when user with same name already exists", async () => {
req.body = { name: "Existing User" };
(validateThirdPartyUserName as any).mockReturnValue(null);
vi.mocked(findThirdPartyUserByName).mockResolvedValue({ id: "00000000-0000-0000-0000-000000000099", name: "Existing User" });

const handler = POST[POST.length - 1];
await handler(req as Request, res as Response, vi.fn());

expect(res.render).toHaveBeenCalledWith(
"create-third-party-user/index",
expect.objectContaining({
errors: expect.arrayContaining([expect.objectContaining({ href: "#name" })]),
name: "Existing User"
})
);
expect(res.redirect).not.toHaveBeenCalled();
});

it("should be case-insensitive for duplicate name check", async () => {
req.body = { name: "existing user" };
(validateThirdPartyUserName as any).mockReturnValue(null);
vi.mocked(findThirdPartyUserByName).mockResolvedValue({ id: "00000000-0000-0000-0000-000000000099", name: "Existing User" });

const handler = POST[POST.length - 1];
await handler(req as Request, res as Response, vi.fn());

expect(res.render).toHaveBeenCalledWith(
"create-third-party-user/index",
expect.objectContaining({
errors: expect.arrayContaining([expect.objectContaining({ href: "#name" })])
})
);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { requireRole, USER_ROLES } from "@hmcts/auth";
import { validateThirdPartyUserName } from "@hmcts/system-admin-pages";
import { findThirdPartyUserByName, validateThirdPartyUserName } from "@hmcts/system-admin-pages";
import type { Request, RequestHandler, Response } from "express";
import { cy } from "./cy.js";
import { en } from "./en.js";
Expand Down Expand Up @@ -42,6 +42,15 @@ const postHandler = async (req: Request, res: Response) => {
});
}

const existingUser = await findThirdPartyUserByName(name!.trim());
if (existingUser) {
return res.render("create-third-party-user/index", {
...content,
errors: [{ href: "#name", text: content.nameDuplicate }],
name: name || ""
});
}

const session = req.session as CreateThirdPartyUserSession;
const idempotencyToken = crypto.randomUUID();

Expand Down
Loading
Loading