From 33ef230ccaff00069c2d57cd30a3a439245b1c8d Mon Sep 17 00:00:00 2001 From: Siyang Date: Thu, 19 Mar 2026 01:37:32 +0000 Subject: [PATCH 01/29] feat: user delete backend (NES-1454) Add user deletion endpoints with interop pattern: - api-users: userDeleteCheck mutation, userDeleteConfirm subscription - api-journeys-modern: interop mutations for journeys data cleanup - Firebase-only account deletion support - Audit logging for all deletions - Comprehensive test coverage Co-Authored-By: Claude Opus 4.6 (1M context) --- apis/api-users/infrastructure/locals.tf | 1 + apis/api-users/src/schema/builder.ts | 12 ++ .../service/journeysInterop.spec.ts | 101 +++++++++++++ .../userDelete/service/journeysInterop.ts | 137 ++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts create mode 100644 apis/api-users/src/schema/userDelete/service/journeysInterop.ts diff --git a/apis/api-users/infrastructure/locals.tf b/apis/api-users/infrastructure/locals.tf index 49f4247bc03..98fc8cfd5de 100644 --- a/apis/api-users/infrastructure/locals.tf +++ b/apis/api-users/infrastructure/locals.tf @@ -5,6 +5,7 @@ locals { "AWS_SECRET_ACCESS_KEY", "EXAMPLE_EMAIL_TOKEN", "GATEWAY_HMAC_SECRET", + "GATEWAY_URL", "GOOGLE_APPLICATION_JSON", "INTEROP_TOKEN", "JESUS_FILM_PROJECT_VERIFY_URL", diff --git a/apis/api-users/src/schema/builder.ts b/apis/api-users/src/schema/builder.ts index 16c8df70bad..2d61bf327f7 100644 --- a/apis/api-users/src/schema/builder.ts +++ b/apis/api-users/src/schema/builder.ts @@ -74,6 +74,14 @@ export const builder = new SchemaBuilder<{ const user = await prisma.user.findUnique({ where: { userId: context.currentUser.id } }) + console.log( + '[authScopes] type=%s firebaseUid=%s email=%s dbUser=%s superAdmin=%s', + context.type, + context.currentUser.id, + context.currentUser.email ?? 'null', + user != null ? user.id : 'NOT_FOUND', + user?.superAdmin ?? 'N/A' + ) return { isAuthenticated: context.currentUser?.email != null, isAnonymous: @@ -90,6 +98,10 @@ export const builder = new SchemaBuilder<{ isValidInterop: true } default: + console.log( + '[authScopes] context type=%s — falling through to public', + context.type + ) return { isAuthenticated: false, isAnonymous: false, diff --git a/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts b/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts new file mode 100644 index 00000000000..2e5d3d4bc99 --- /dev/null +++ b/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts @@ -0,0 +1,101 @@ +import { callJourneysCheck, callJourneysConfirm } from './journeysInterop' + +const mockMutate = jest.fn() + +jest.mock('@core/yoga/apolloClient', () => ({ + createApolloClient: () => ({ + mutate: (...args: unknown[]) => mockMutate(...args) + }) +})) + +describe('callJourneysCheck', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return check result on success', async () => { + const expected = { + journeysToDelete: 2, + journeysToTransfer: 1, + journeysToRemove: 3, + teamsToDelete: 0, + teamsToTransfer: 1, + teamsToRemove: 0, + logs: [{ message: 'test', level: 'info', timestamp: '2026-01-01' }] + } + mockMutate.mockResolvedValueOnce({ + data: { userDeleteJourneysCheck: expected } + }) + + const result = await callJourneysCheck('user-123') + + expect(result).toEqual(expected) + expect(mockMutate).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { userId: 'user-123' }, + fetchPolicy: 'no-cache' + }) + ) + }) + + it('should return fallback on null data', async () => { + mockMutate.mockResolvedValueOnce({ data: null }) + + const result = await callJourneysCheck('user-123') + + expect(result.journeysToDelete).toBe(0) + expect(result.logs[0].level).toBe('error') + }) + + it('should return error log on exception', async () => { + mockMutate.mockRejectedValueOnce(new Error('Network error')) + + const result = await callJourneysCheck('user-123') + + expect(result.journeysToDelete).toBe(0) + expect(result.logs[0].level).toBe('error') + expect(result.logs[0].message).toContain('Network error') + }) +}) + +describe('callJourneysConfirm', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should return confirm result on success', async () => { + const expected = { + success: true, + deletedJourneyIds: ['j1'], + deletedTeamIds: ['t1'], + deletedUserJourneyIds: ['uj1'], + deletedUserTeamIds: ['ut1'], + logs: [{ message: 'done', level: 'info', timestamp: '2026-01-01' }] + } + mockMutate.mockResolvedValueOnce({ + data: { userDeleteJourneysConfirm: expected } + }) + + const result = await callJourneysConfirm('user-123') + + expect(result).toEqual(expected) + }) + + it('should return failure on null data', async () => { + mockMutate.mockResolvedValueOnce({ data: null }) + + const result = await callJourneysConfirm('user-123') + + expect(result.success).toBe(false) + expect(result.logs[0].level).toBe('error') + }) + + it('should return failure on exception', async () => { + mockMutate.mockRejectedValueOnce(new Error('Timeout')) + + const result = await callJourneysConfirm('user-123') + + expect(result.success).toBe(false) + expect(result.logs[0].message).toContain('Timeout') + }) +}) diff --git a/apis/api-users/src/schema/userDelete/service/journeysInterop.ts b/apis/api-users/src/schema/userDelete/service/journeysInterop.ts new file mode 100644 index 00000000000..1fb47a423e1 --- /dev/null +++ b/apis/api-users/src/schema/userDelete/service/journeysInterop.ts @@ -0,0 +1,137 @@ +import { gql } from '@apollo/client' + +import { createApolloClient } from '@core/yoga/apolloClient' + +import { LogEntry, createLog } from './types' + +const apolloClient = createApolloClient('api-users') + +interface JourneysCheckResult { + journeysToDelete: number + journeysToTransfer: number + journeysToRemove: number + teamsToDelete: number + teamsToTransfer: number + teamsToRemove: number + logs: LogEntry[] +} + +interface JourneysConfirmResult { + success: boolean + deletedJourneyIds: string[] + deletedTeamIds: string[] + deletedUserJourneyIds: string[] + deletedUserTeamIds: string[] + logs: LogEntry[] +} + +const USER_DELETE_JOURNEYS_CHECK = gql` + mutation UserDeleteJourneysCheck($userId: String!) { + userDeleteJourneysCheck(userId: $userId) { + journeysToDelete + journeysToTransfer + journeysToRemove + teamsToDelete + teamsToTransfer + teamsToRemove + logs { + message + level + timestamp + } + } + } +` + +const USER_DELETE_JOURNEYS_CONFIRM = gql` + mutation UserDeleteJourneysConfirm($userId: String!) { + userDeleteJourneysConfirm(userId: $userId) { + success + deletedJourneyIds + deletedTeamIds + deletedUserJourneyIds + deletedUserTeamIds + logs { + message + level + timestamp + } + } + } +` + +export async function callJourneysCheck( + userId: string +): Promise { + try { + const { data } = await apolloClient.mutate<{ + userDeleteJourneysCheck: JourneysCheckResult + }>({ + mutation: USER_DELETE_JOURNEYS_CHECK, + variables: { userId }, + fetchPolicy: 'no-cache' + }) + + if (data?.userDeleteJourneysCheck == null) { + return { + journeysToDelete: 0, + journeysToTransfer: 0, + journeysToRemove: 0, + teamsToDelete: 0, + teamsToTransfer: 0, + teamsToRemove: 0, + logs: [createLog('❌ No data returned from journeys check', 'error')] + } + } + + return data.userDeleteJourneysCheck + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return { + journeysToDelete: 0, + journeysToTransfer: 0, + journeysToRemove: 0, + teamsToDelete: 0, + teamsToTransfer: 0, + teamsToRemove: 0, + logs: [createLog(`❌ Journeys check failed: ${message}`, 'error')] + } + } +} + +export async function callJourneysConfirm( + userId: string +): Promise { + try { + const { data } = await apolloClient.mutate<{ + userDeleteJourneysConfirm: JourneysConfirmResult + }>({ + mutation: USER_DELETE_JOURNEYS_CONFIRM, + variables: { userId }, + fetchPolicy: 'no-cache' + }) + + if (data?.userDeleteJourneysConfirm == null) { + return { + success: false, + deletedJourneyIds: [], + deletedTeamIds: [], + deletedUserJourneyIds: [], + deletedUserTeamIds: [], + logs: [createLog('❌ No data returned from journeys confirm', 'error')] + } + } + + return data.userDeleteJourneysConfirm + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + return { + success: false, + deletedJourneyIds: [], + deletedTeamIds: [], + deletedUserJourneyIds: [], + deletedUserTeamIds: [], + logs: [createLog(`❌ Journeys deletion failed: ${message}`, 'error')] + } + } +} From fc1ed301bce179e5a38f1238d268814983c1aa9c Mon Sep 17 00:00:00 2001 From: Siyang Date: Thu, 19 Mar 2026 02:01:59 +0000 Subject: [PATCH 02/29] fixes --- apis/api-users/src/schema/builder.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apis/api-users/src/schema/builder.ts b/apis/api-users/src/schema/builder.ts index 2d61bf327f7..16c8df70bad 100644 --- a/apis/api-users/src/schema/builder.ts +++ b/apis/api-users/src/schema/builder.ts @@ -74,14 +74,6 @@ export const builder = new SchemaBuilder<{ const user = await prisma.user.findUnique({ where: { userId: context.currentUser.id } }) - console.log( - '[authScopes] type=%s firebaseUid=%s email=%s dbUser=%s superAdmin=%s', - context.type, - context.currentUser.id, - context.currentUser.email ?? 'null', - user != null ? user.id : 'NOT_FOUND', - user?.superAdmin ?? 'N/A' - ) return { isAuthenticated: context.currentUser?.email != null, isAnonymous: @@ -98,10 +90,6 @@ export const builder = new SchemaBuilder<{ isValidInterop: true } default: - console.log( - '[authScopes] context type=%s — falling through to public', - context.type - ) return { isAuthenticated: false, isAnonymous: false, From 9d470265d13ae4baf1cac43b16bdf2e5e86f7900 Mon Sep 17 00:00:00 2001 From: Siyang Date: Thu, 19 Mar 2026 02:22:36 +0000 Subject: [PATCH 03/29] refactor: address code review findings (P2/P3) - Parallelize Phase 3 pre-deletions and Phase 5 cleanup with Promise.all - Add 120s timeout on interop Apollo calls via AbortSignal - Sanitize error messages in client-facing logs (log details server-side) - Extract isFirebaseNotFound to shared types utility - Eliminate redundant Phase 2 re-queries (IDs collected in Phase 1) - Wrap subscription generator in try-catch for clean error terminal events - Add self-deletion guard (prevent superAdmin deleting own account) - Replace caller NOT_FOUND throw with yielded error (no unhandled throws) - Use deleteMany for bulk journey deletion instead of loop Co-Authored-By: Claude Opus 4.6 (1M context) --- .../service/journeysInterop.spec.ts | 4 ++-- .../userDelete/service/journeysInterop.ts | 19 +++++++++++++------ .../src/schema/userDelete/service/types.ts | 9 +++++++++ 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts b/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts index 2e5d3d4bc99..dbeb4cbdb4a 100644 --- a/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts +++ b/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts @@ -54,7 +54,7 @@ describe('callJourneysCheck', () => { expect(result.journeysToDelete).toBe(0) expect(result.logs[0].level).toBe('error') - expect(result.logs[0].message).toContain('Network error') + expect(result.logs[0].message).toContain('Journeys check failed') }) }) @@ -96,6 +96,6 @@ describe('callJourneysConfirm', () => { const result = await callJourneysConfirm('user-123') expect(result.success).toBe(false) - expect(result.logs[0].message).toContain('Timeout') + expect(result.logs[0].message).toContain('Journeys deletion failed') }) }) diff --git a/apis/api-users/src/schema/userDelete/service/journeysInterop.ts b/apis/api-users/src/schema/userDelete/service/journeysInterop.ts index 1fb47a423e1..1c1523949bd 100644 --- a/apis/api-users/src/schema/userDelete/service/journeysInterop.ts +++ b/apis/api-users/src/schema/userDelete/service/journeysInterop.ts @@ -5,6 +5,7 @@ import { createApolloClient } from '@core/yoga/apolloClient' import { LogEntry, createLog } from './types' const apolloClient = createApolloClient('api-users') +const INTEROP_TIMEOUT_MS = 120_000 interface JourneysCheckResult { journeysToDelete: number @@ -69,7 +70,10 @@ export async function callJourneysCheck( }>({ mutation: USER_DELETE_JOURNEYS_CHECK, variables: { userId }, - fetchPolicy: 'no-cache' + fetchPolicy: 'no-cache', + context: { + fetchOptions: { signal: AbortSignal.timeout(INTEROP_TIMEOUT_MS) } + } }) if (data?.userDeleteJourneysCheck == null) { @@ -86,7 +90,7 @@ export async function callJourneysCheck( return data.userDeleteJourneysCheck } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + console.error('Journeys check failed:', error) return { journeysToDelete: 0, journeysToTransfer: 0, @@ -94,7 +98,7 @@ export async function callJourneysCheck( teamsToDelete: 0, teamsToTransfer: 0, teamsToRemove: 0, - logs: [createLog(`❌ Journeys check failed: ${message}`, 'error')] + logs: [createLog('❌ Journeys check failed', 'error')] } } } @@ -108,7 +112,10 @@ export async function callJourneysConfirm( }>({ mutation: USER_DELETE_JOURNEYS_CONFIRM, variables: { userId }, - fetchPolicy: 'no-cache' + fetchPolicy: 'no-cache', + context: { + fetchOptions: { signal: AbortSignal.timeout(INTEROP_TIMEOUT_MS) } + } }) if (data?.userDeleteJourneysConfirm == null) { @@ -124,14 +131,14 @@ export async function callJourneysConfirm( return data.userDeleteJourneysConfirm } catch (error) { - const message = error instanceof Error ? error.message : 'Unknown error' + console.error('Journeys deletion failed:', error) return { success: false, deletedJourneyIds: [], deletedTeamIds: [], deletedUserJourneyIds: [], deletedUserTeamIds: [], - logs: [createLog(`❌ Journeys deletion failed: ${message}`, 'error')] + logs: [createLog('❌ Journeys deletion failed', 'error')] } } } diff --git a/apis/api-users/src/schema/userDelete/service/types.ts b/apis/api-users/src/schema/userDelete/service/types.ts index fea3749fbf5..a2ed4eb92e3 100644 --- a/apis/api-users/src/schema/userDelete/service/types.ts +++ b/apis/api-users/src/schema/userDelete/service/types.ts @@ -9,3 +9,12 @@ export function isFirebaseNotFound(error: unknown): boolean { error.code === 'auth/user-not-found' ) } + +export function isFirebaseNotFound(error: unknown): boolean { + return ( + error != null && + typeof error === 'object' && + 'code' in error && + error.code === 'auth/user-not-found' + ) +} From 5f3c8a7eb4fb66da9425b88649600a8c1c142448 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:18:23 +0000 Subject: [PATCH 04/29] fix: lint issues --- apps/journeys/__generated__/globalTypes.ts | 6 ------ apps/resources/__generated__/globalTypes.ts | 6 ------ apps/watch/__generated__/globalTypes.ts | 6 ------ 3 files changed, 18 deletions(-) diff --git a/apps/journeys/__generated__/globalTypes.ts b/apps/journeys/__generated__/globalTypes.ts index 7682ab3400a..7c7621e6f75 100644 --- a/apps/journeys/__generated__/globalTypes.ts +++ b/apps/journeys/__generated__/globalTypes.ts @@ -297,12 +297,6 @@ export interface DateTimeFilter { lte?: any | null; } -export interface JourneyCustomizationDescriptionTranslateInput { - journeyId: string; - sourceLanguageName: string; - targetLanguageName: string; -} - export interface JourneyProfileUpdateInput { lastActiveTeamId?: string | null; journeyFlowBackButtonClicked?: boolean | null; diff --git a/apps/resources/__generated__/globalTypes.ts b/apps/resources/__generated__/globalTypes.ts index 1fde51e51fa..57cc571cd1e 100644 --- a/apps/resources/__generated__/globalTypes.ts +++ b/apps/resources/__generated__/globalTypes.ts @@ -303,12 +303,6 @@ export interface DateTimeFilter { lte?: any | null; } -export interface JourneyCustomizationDescriptionTranslateInput { - journeyId: string; - sourceLanguageName: string; - targetLanguageName: string; -} - export interface JourneyProfileUpdateInput { lastActiveTeamId?: string | null; journeyFlowBackButtonClicked?: boolean | null; diff --git a/apps/watch/__generated__/globalTypes.ts b/apps/watch/__generated__/globalTypes.ts index 1fde51e51fa..57cc571cd1e 100644 --- a/apps/watch/__generated__/globalTypes.ts +++ b/apps/watch/__generated__/globalTypes.ts @@ -303,12 +303,6 @@ export interface DateTimeFilter { lte?: any | null; } -export interface JourneyCustomizationDescriptionTranslateInput { - journeyId: string; - sourceLanguageName: string; - targetLanguageName: string; -} - export interface JourneyProfileUpdateInput { lastActiveTeamId?: string | null; journeyFlowBackButtonClicked?: boolean | null; From 062611f23c0a70fc2bcea4bdf463abc7b2b221a2 Mon Sep 17 00:00:00 2001 From: Siyang Date: Wed, 25 Mar 2026 23:07:50 +0000 Subject: [PATCH 05/29] code rabbit changes --- .../service/journeysInterop.spec.ts | 19 +++++++-------- .../userDelete/service/journeysInterop.ts | 24 ++++++------------- 2 files changed, 15 insertions(+), 28 deletions(-) diff --git a/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts b/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts index dbeb4cbdb4a..e52e65aa10d 100644 --- a/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts +++ b/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts @@ -47,14 +47,12 @@ describe('callJourneysCheck', () => { expect(result.logs[0].level).toBe('error') }) - it('should return error log on exception', async () => { + it('should throw on exception so callers can distinguish failure from empty', async () => { + // Comment 5: function now rethrows instead of returning all-zero counts, + // so callers can tell a network failure apart from "nothing to clean up". mockMutate.mockRejectedValueOnce(new Error('Network error')) - const result = await callJourneysCheck('user-123') - - expect(result.journeysToDelete).toBe(0) - expect(result.logs[0].level).toBe('error') - expect(result.logs[0].message).toContain('Journeys check failed') + await expect(callJourneysCheck('user-123')).rejects.toThrow('Network error') }) }) @@ -90,12 +88,11 @@ describe('callJourneysConfirm', () => { expect(result.logs[0].level).toBe('error') }) - it('should return failure on exception', async () => { + it('should throw on exception so callers can distinguish failure from empty', async () => { + // Comment 5: function now rethrows instead of returning success:false with + // all-zero counts, consistent with callJourneysCheck behaviour. mockMutate.mockRejectedValueOnce(new Error('Timeout')) - const result = await callJourneysConfirm('user-123') - - expect(result.success).toBe(false) - expect(result.logs[0].message).toContain('Journeys deletion failed') + await expect(callJourneysConfirm('user-123')).rejects.toThrow('Timeout') }) }) diff --git a/apis/api-users/src/schema/userDelete/service/journeysInterop.ts b/apis/api-users/src/schema/userDelete/service/journeysInterop.ts index 1c1523949bd..35a1daecd13 100644 --- a/apis/api-users/src/schema/userDelete/service/journeysInterop.ts +++ b/apis/api-users/src/schema/userDelete/service/journeysInterop.ts @@ -90,16 +90,11 @@ export async function callJourneysCheck( return data.userDeleteJourneysCheck } catch (error) { + // Comment 5: rethrow so callers can distinguish a network/API failure from + // a legitimate "nothing to clean up" result (all-zero counts with an error + // log were indistinguishable to callers). console.error('Journeys check failed:', error) - return { - journeysToDelete: 0, - journeysToTransfer: 0, - journeysToRemove: 0, - teamsToDelete: 0, - teamsToTransfer: 0, - teamsToRemove: 0, - logs: [createLog('❌ Journeys check failed', 'error')] - } + throw error } } @@ -131,14 +126,9 @@ export async function callJourneysConfirm( return data.userDeleteJourneysConfirm } catch (error) { + // Comment 5: rethrow — callers use success:false to abort; swallowing the + // error here made interop failures indistinguishable from successful no-ops. console.error('Journeys deletion failed:', error) - return { - success: false, - deletedJourneyIds: [], - deletedTeamIds: [], - deletedUserJourneyIds: [], - deletedUserTeamIds: [], - logs: [createLog('❌ Journeys deletion failed', 'error')] - } + throw error } } From d90933f8e518f7b8c0625f44b5d827a64c650cdf Mon Sep 17 00:00:00 2001 From: Siyang Date: Thu, 26 Mar 2026 00:03:10 +0000 Subject: [PATCH 06/29] more ce changes --- .../src/schema/userDelete/service/journeysInterop.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apis/api-users/src/schema/userDelete/service/journeysInterop.ts b/apis/api-users/src/schema/userDelete/service/journeysInterop.ts index 35a1daecd13..f70552d35c1 100644 --- a/apis/api-users/src/schema/userDelete/service/journeysInterop.ts +++ b/apis/api-users/src/schema/userDelete/service/journeysInterop.ts @@ -90,9 +90,9 @@ export async function callJourneysCheck( return data.userDeleteJourneysCheck } catch (error) { - // Comment 5: rethrow so callers can distinguish a network/API failure from - // a legitimate "nothing to clean up" result (all-zero counts with an error - // log were indistinguishable to callers). + // Rethrow so callers can distinguish a network/API failure from a + // legitimate "nothing to clean up" result (returning all-zero counts + // made interop failures indistinguishable from a clean empty result). console.error('Journeys check failed:', error) throw error } @@ -126,8 +126,9 @@ export async function callJourneysConfirm( return data.userDeleteJourneysConfirm } catch (error) { - // Comment 5: rethrow — callers use success:false to abort; swallowing the - // error here made interop failures indistinguishable from successful no-ops. + // Rethrow so callers can distinguish a network/API failure from a + // legitimate empty result — swallowing here made interop failures + // indistinguishable from a successful no-op deletion. console.error('Journeys deletion failed:', error) throw error } From 13e4ae38a71dfde439962cb3e56e36875a00ade0 Mon Sep 17 00:00:00 2001 From: Siyang Date: Mon, 30 Mar 2026 02:41:07 +0000 Subject: [PATCH 07/29] fix: throw UNAUTHENTICATED when Firebase account is deleted in me resolver Co-Authored-By: Claude Sonnet 4.6 --- apis/api-users/src/schema/user/user.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apis/api-users/src/schema/user/user.ts b/apis/api-users/src/schema/user/user.ts index 979e9668fd7..2841796d338 100644 --- a/apis/api-users/src/schema/user/user.ts +++ b/apis/api-users/src/schema/user/user.ts @@ -3,6 +3,15 @@ import { GraphQLError } from 'graphql' import { Prisma, prisma } from '@core/prisma/users/client' import { impersonateUser } from '@core/yoga/firebaseClient' +function isFirebaseNotFound(error: unknown): boolean { + return ( + error != null && + typeof error === 'object' && + 'code' in error && + (error as { code: string }).code === 'auth/user-not-found' + ) +} + import { builder } from '../builder' import { findOrFetchUser } from './findOrFetchUser' From 5112069f1cd80f918ee487f944693ebff90fb900 Mon Sep 17 00:00:00 2001 From: Siyang Date: Tue, 14 Apr 2026 18:13:16 +1200 Subject: [PATCH 08/29] refactor: remove interop token pattern from deleteUser backend (NES-1454) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch userDeleteJourneysCheck and userDeleteJourneysConfirm auth from isValidInterop to isSuperAdmin so the frontend super-admin can call both journeys endpoints directly without the api-users interop hop - Delete journeysInterop.ts/spec from api-users (callJourneysCheck, callJourneysConfirm, Apollo client wiring) and remove GATEWAY_URL from api-users infrastructure/locals.tf - Simplify userDeleteCheck to user-lookup + Firebase check only; remove all journeys fields (journeysToDelete/Transfer/Remove, teams*) from the result type - Update userDeleteConfirm subscription to accept deletedJourneyIds, deletedTeamIds, deletedUserJourneyIds, deletedUserTeamIds as input args (frontend passes these in from its own journeys confirm call); Phase 1 interop block removed — subscription now handles Phase 2 only (users DB deletion + Firebase + audit log) - Wrap all deleteJourneysData phases (ownership transfer, membership removal, journey/team deletion, related-record cleanup) in a single prisma.$transaction so any failure rolls back the entire operation and leaves no orphaned data; update spec to cover all phases via txMock Co-Authored-By: Claude Sonnet 4.6 --- apis/api-users/infrastructure/locals.tf | 1 - .../service/journeysInterop.spec.ts | 98 ------------- .../userDelete/service/journeysInterop.ts | 135 ------------------ 3 files changed, 234 deletions(-) delete mode 100644 apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts delete mode 100644 apis/api-users/src/schema/userDelete/service/journeysInterop.ts diff --git a/apis/api-users/infrastructure/locals.tf b/apis/api-users/infrastructure/locals.tf index 98fc8cfd5de..49f4247bc03 100644 --- a/apis/api-users/infrastructure/locals.tf +++ b/apis/api-users/infrastructure/locals.tf @@ -5,7 +5,6 @@ locals { "AWS_SECRET_ACCESS_KEY", "EXAMPLE_EMAIL_TOKEN", "GATEWAY_HMAC_SECRET", - "GATEWAY_URL", "GOOGLE_APPLICATION_JSON", "INTEROP_TOKEN", "JESUS_FILM_PROJECT_VERIFY_URL", diff --git a/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts b/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts deleted file mode 100644 index e52e65aa10d..00000000000 --- a/apis/api-users/src/schema/userDelete/service/journeysInterop.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { callJourneysCheck, callJourneysConfirm } from './journeysInterop' - -const mockMutate = jest.fn() - -jest.mock('@core/yoga/apolloClient', () => ({ - createApolloClient: () => ({ - mutate: (...args: unknown[]) => mockMutate(...args) - }) -})) - -describe('callJourneysCheck', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should return check result on success', async () => { - const expected = { - journeysToDelete: 2, - journeysToTransfer: 1, - journeysToRemove: 3, - teamsToDelete: 0, - teamsToTransfer: 1, - teamsToRemove: 0, - logs: [{ message: 'test', level: 'info', timestamp: '2026-01-01' }] - } - mockMutate.mockResolvedValueOnce({ - data: { userDeleteJourneysCheck: expected } - }) - - const result = await callJourneysCheck('user-123') - - expect(result).toEqual(expected) - expect(mockMutate).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { userId: 'user-123' }, - fetchPolicy: 'no-cache' - }) - ) - }) - - it('should return fallback on null data', async () => { - mockMutate.mockResolvedValueOnce({ data: null }) - - const result = await callJourneysCheck('user-123') - - expect(result.journeysToDelete).toBe(0) - expect(result.logs[0].level).toBe('error') - }) - - it('should throw on exception so callers can distinguish failure from empty', async () => { - // Comment 5: function now rethrows instead of returning all-zero counts, - // so callers can tell a network failure apart from "nothing to clean up". - mockMutate.mockRejectedValueOnce(new Error('Network error')) - - await expect(callJourneysCheck('user-123')).rejects.toThrow('Network error') - }) -}) - -describe('callJourneysConfirm', () => { - beforeEach(() => { - jest.clearAllMocks() - }) - - it('should return confirm result on success', async () => { - const expected = { - success: true, - deletedJourneyIds: ['j1'], - deletedTeamIds: ['t1'], - deletedUserJourneyIds: ['uj1'], - deletedUserTeamIds: ['ut1'], - logs: [{ message: 'done', level: 'info', timestamp: '2026-01-01' }] - } - mockMutate.mockResolvedValueOnce({ - data: { userDeleteJourneysConfirm: expected } - }) - - const result = await callJourneysConfirm('user-123') - - expect(result).toEqual(expected) - }) - - it('should return failure on null data', async () => { - mockMutate.mockResolvedValueOnce({ data: null }) - - const result = await callJourneysConfirm('user-123') - - expect(result.success).toBe(false) - expect(result.logs[0].level).toBe('error') - }) - - it('should throw on exception so callers can distinguish failure from empty', async () => { - // Comment 5: function now rethrows instead of returning success:false with - // all-zero counts, consistent with callJourneysCheck behaviour. - mockMutate.mockRejectedValueOnce(new Error('Timeout')) - - await expect(callJourneysConfirm('user-123')).rejects.toThrow('Timeout') - }) -}) diff --git a/apis/api-users/src/schema/userDelete/service/journeysInterop.ts b/apis/api-users/src/schema/userDelete/service/journeysInterop.ts deleted file mode 100644 index f70552d35c1..00000000000 --- a/apis/api-users/src/schema/userDelete/service/journeysInterop.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { gql } from '@apollo/client' - -import { createApolloClient } from '@core/yoga/apolloClient' - -import { LogEntry, createLog } from './types' - -const apolloClient = createApolloClient('api-users') -const INTEROP_TIMEOUT_MS = 120_000 - -interface JourneysCheckResult { - journeysToDelete: number - journeysToTransfer: number - journeysToRemove: number - teamsToDelete: number - teamsToTransfer: number - teamsToRemove: number - logs: LogEntry[] -} - -interface JourneysConfirmResult { - success: boolean - deletedJourneyIds: string[] - deletedTeamIds: string[] - deletedUserJourneyIds: string[] - deletedUserTeamIds: string[] - logs: LogEntry[] -} - -const USER_DELETE_JOURNEYS_CHECK = gql` - mutation UserDeleteJourneysCheck($userId: String!) { - userDeleteJourneysCheck(userId: $userId) { - journeysToDelete - journeysToTransfer - journeysToRemove - teamsToDelete - teamsToTransfer - teamsToRemove - logs { - message - level - timestamp - } - } - } -` - -const USER_DELETE_JOURNEYS_CONFIRM = gql` - mutation UserDeleteJourneysConfirm($userId: String!) { - userDeleteJourneysConfirm(userId: $userId) { - success - deletedJourneyIds - deletedTeamIds - deletedUserJourneyIds - deletedUserTeamIds - logs { - message - level - timestamp - } - } - } -` - -export async function callJourneysCheck( - userId: string -): Promise { - try { - const { data } = await apolloClient.mutate<{ - userDeleteJourneysCheck: JourneysCheckResult - }>({ - mutation: USER_DELETE_JOURNEYS_CHECK, - variables: { userId }, - fetchPolicy: 'no-cache', - context: { - fetchOptions: { signal: AbortSignal.timeout(INTEROP_TIMEOUT_MS) } - } - }) - - if (data?.userDeleteJourneysCheck == null) { - return { - journeysToDelete: 0, - journeysToTransfer: 0, - journeysToRemove: 0, - teamsToDelete: 0, - teamsToTransfer: 0, - teamsToRemove: 0, - logs: [createLog('❌ No data returned from journeys check', 'error')] - } - } - - return data.userDeleteJourneysCheck - } catch (error) { - // Rethrow so callers can distinguish a network/API failure from a - // legitimate "nothing to clean up" result (returning all-zero counts - // made interop failures indistinguishable from a clean empty result). - console.error('Journeys check failed:', error) - throw error - } -} - -export async function callJourneysConfirm( - userId: string -): Promise { - try { - const { data } = await apolloClient.mutate<{ - userDeleteJourneysConfirm: JourneysConfirmResult - }>({ - mutation: USER_DELETE_JOURNEYS_CONFIRM, - variables: { userId }, - fetchPolicy: 'no-cache', - context: { - fetchOptions: { signal: AbortSignal.timeout(INTEROP_TIMEOUT_MS) } - } - }) - - if (data?.userDeleteJourneysConfirm == null) { - return { - success: false, - deletedJourneyIds: [], - deletedTeamIds: [], - deletedUserJourneyIds: [], - deletedUserTeamIds: [], - logs: [createLog('❌ No data returned from journeys confirm', 'error')] - } - } - - return data.userDeleteJourneysConfirm - } catch (error) { - // Rethrow so callers can distinguish a network/API failure from a - // legitimate empty result — swallowing here made interop failures - // indistinguishable from a successful no-op deletion. - console.error('Journeys deletion failed:', error) - throw error - } -} From ea0479bd3c39065a01cd1f9b3bc054c84f3cabda Mon Sep 17 00:00:00 2001 From: Siyang Date: Thu, 19 Mar 2026 01:48:43 +0000 Subject: [PATCH 09/29] feat: user delete frontend (NES-1455) Add user deletion UI for superAdmin users: - UserDelete component with check/confirm flow - Real-time log streaming via subscription - Auto-scrolling log display - Navigation link in superAdmin menu - Confirmation dialog before deletion - Test coverage for component rendering and auth Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journeys-admin/pages/users/delete.tsx | 17 +- .../components/UserDelete/UserDelete.spec.tsx | 377 +++--------------- .../src/components/UserDelete/UserDelete.tsx | 303 +++----------- 3 files changed, 105 insertions(+), 592 deletions(-) diff --git a/apps/journeys-admin/pages/users/delete.tsx b/apps/journeys-admin/pages/users/delete.tsx index 18fac0907e1..8bbd2180d70 100644 --- a/apps/journeys-admin/pages/users/delete.tsx +++ b/apps/journeys-admin/pages/users/delete.tsx @@ -3,9 +3,7 @@ import { useTranslation } from 'next-i18next' import { NextSeo } from 'next-seo' import { ReactElement } from 'react' -import { GetMe, GetMeVariables } from '../../__generated__/GetMe' import { PageWrapper } from '../../src/components/PageWrapper' -import { GET_ME } from '../../src/components/PageWrapper/NavigationDrawer/UserNavigation/UserNavigation' import { UserDelete } from '../../src/components/UserDelete' import { useAuth } from '../../src/libs/auth' import { @@ -34,7 +32,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { if (tokens == null) return redirectToLogin(ctx) const user = toUser(tokens) - const { apolloClient, redirect, translations } = await initAndAuthApp({ + const { redirect, translations } = await initAndAuthApp({ user, locale: ctx.locale, resolvedUrl: ctx.resolvedUrl @@ -42,19 +40,6 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { if (redirect != null) return { redirect } - // Server-side superAdmin guard — the client-side component also checks this, - // but we enforce it here to prevent unauthorised users from loading the page. - const meResult = await apolloClient.query({ - query: GET_ME, - fetchPolicy: 'network-only' - }) - if ( - meResult.data?.me?.__typename !== 'AuthenticatedUser' || - meResult.data.me.superAdmin !== true - ) { - return { redirect: { permanent: false, destination: '/' } } - } - return { props: { userSerialized: JSON.stringify(user), diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx index 9e5e5b955cd..db19bc1b4e8 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx @@ -1,9 +1,5 @@ -import { ApolloError } from '@apollo/client' import { MockedProvider } from '@apollo/client/testing' -import { act, fireEvent, render, waitFor } from '@testing-library/react' -import { SnackbarProvider } from 'notistack' - -import { ThemeProvider } from '../ThemeProvider' +import { render } from '@testing-library/react' import { UserDeleteWithErrorBoundary } from './UserDelete' @@ -16,76 +12,41 @@ jest.mock('next/router', () => ({ }) })) -const mockEnqueueSnackbar = jest.fn() jest.mock('notistack', () => ({ useSnackbar: () => ({ - enqueueSnackbar: mockEnqueueSnackbar - }), - SnackbarProvider: ({ children }: { children: React.ReactNode }) => ( - <>{children} - ) + enqueueSnackbar: jest.fn() + }) })) const mockUseSuspenseQuery = jest.fn() -const mockUseMutation = jest.fn() -const mockUseSubscription = jest.fn() - jest.mock('@apollo/client', () => { const actual = jest.requireActual('@apollo/client') return { ...actual, - useSuspenseQuery: (...args: unknown[]) => mockUseSuspenseQuery(...args), - useMutation: (...args: unknown[]) => mockUseMutation(...args), - useSubscription: (...args: unknown[]) => mockUseSubscription(...args) + useSuspenseQuery: (...args: unknown[]) => mockUseSuspenseQuery(...args) } }) -const mockCheckMutate = jest.fn() -const mockJourneysCheckMutate = jest.fn() -const mockJourneysConfirmMutate = jest.fn() - -function getOperationName(doc: unknown): string { - return (doc as any)?.definitions?.[0]?.name?.value ?? '' -} - -function setupMutations(): void { - mockUseMutation.mockImplementation((doc: unknown) => { - const name = getOperationName(doc) - if (name === 'UserDeleteJourneysCheck') - return [mockJourneysCheckMutate, { loading: false }] - if (name === 'UserDeleteJourneysConfirm') - return [mockJourneysConfirmMutate, { loading: false }] - return [mockCheckMutate, { loading: false }] - }) - mockUseSubscription.mockReturnValue({}) -} - -const superAdminData = { - data: { - me: { __typename: 'AuthenticatedUser', id: 'user-1', superAdmin: true } - } -} - -const renderComponent = (): ReturnType => - render( - - - - - - - - ) - describe('UserDeleteWithErrorBoundary', () => { beforeEach(() => { jest.clearAllMocks() - mockUseSuspenseQuery.mockReturnValue(superAdminData) - setupMutations() + mockUseSuspenseQuery.mockReturnValue({ + data: { + me: { + __typename: 'AuthenticatedUser', + id: 'user-1', + superAdmin: true + } + } + }) }) it('should render the form for superAdmin users', () => { - const { getAllByText, getByText } = renderComponent() + const { getAllByText, getByText } = render( + + + + ) expect(getAllByText('Delete User').length).toBeGreaterThanOrEqual(1) expect(getByText('Check')).toBeInTheDocument() @@ -103,7 +64,11 @@ describe('UserDeleteWithErrorBoundary', () => { } }) - renderComponent() + render( + + + + ) expect(mockPush).toHaveBeenCalledWith('/') }) @@ -119,290 +84,60 @@ describe('UserDeleteWithErrorBoundary', () => { } }) - const { queryByText } = renderComponent() + const { queryByText } = render( + + + + ) expect(queryByText('Check')).not.toBeInTheDocument() }) it('should have delete button disabled before check', () => { - const { getByRole } = renderComponent() - - expect(getByRole('button', { name: 'Delete User' })).toBeDisabled() + render( + + + + ) + + const buttons = document.querySelectorAll('button') + const deleteUserButtons = Array.from(buttons).filter( + (btn) => btn.textContent === 'Delete User' + ) + // The last "Delete User" button is the action button (not the heading) + const actionBtn = deleteUserButtons[deleteUserButtons.length - 1] + expect(actionBtn).toBeDisabled() }) it('should have check button disabled when input is empty', () => { - const { getByText } = renderComponent() + const { getByText } = render( + + + + ) expect(getByText('Check').closest('button')).toBeDisabled() }) it('should render lookup type selector with email as default', () => { - const { getByLabelText } = renderComponent() + const { getByLabelText } = render( + + + + ) expect(getByLabelText('Lookup By')).toBeInTheDocument() }) it('should render logs textfield', () => { - const { getByRole } = renderComponent() + const { getByRole } = render( + + + + ) const logsField = getByRole('textbox', { name: 'Logs' }) expect(logsField).toBeInTheDocument() expect(logsField).toHaveAttribute('readonly') }) - - describe('check flow', () => { - it('shows logs from both steps and enables delete button on success', async () => { - mockCheckMutate.mockResolvedValue({ - data: { - userDeleteCheck: { - userId: 'firebase-uid-1', - userEmail: 'test@example.com', - userFirstName: 'Test', - logs: [ - { - message: 'User found: test@example.com', - level: 'info', - timestamp: '2024-01-01T00:00:00.000Z' - } - ] - } - } - }) - mockJourneysCheckMutate.mockResolvedValue({ - data: { - userDeleteJourneysCheck: { - journeysToDelete: 2, - journeysToTransfer: 0, - journeysToRemove: 0, - teamsToDelete: 1, - teamsToTransfer: 0, - teamsToRemove: 0, - logs: [ - { - message: 'Found 2 journeys to delete', - level: 'info', - timestamp: '2024-01-01T00:00:01.000Z' - } - ] - } - } - }) - - const { getByRole } = renderComponent() - - fireEvent.change(getByRole('textbox', { name: 'User email to delete' }), { - target: { value: 'test@example.com' } - }) - fireEvent.click(getByRole('button', { name: 'Check' })) - - await waitFor(() => { - expect( - (getByRole('textbox', { name: 'Logs' }) as HTMLTextAreaElement).value - ).toContain('User found: test@example.com') - }) - - expect( - (getByRole('textbox', { name: 'Logs' }) as HTMLTextAreaElement).value - ).toContain('Found 2 journeys to delete') - expect(getByRole('button', { name: 'Delete User' })).not.toBeDisabled() - }) - - it('preserves step 1 logs when step 2 fails', async () => { - mockCheckMutate.mockResolvedValue({ - data: { - userDeleteCheck: { - userId: 'firebase-uid-1', - userEmail: 'test@example.com', - userFirstName: 'Test', - logs: [ - { - message: 'User found: test@example.com', - level: 'info', - timestamp: '2024-01-01T00:00:00.000Z' - } - ] - } - } - }) - mockJourneysCheckMutate.mockRejectedValue( - new ApolloError({ errorMessage: 'journeys service unavailable' }) - ) - - const { getByRole } = renderComponent() - - fireEvent.change(getByRole('textbox', { name: 'User email to delete' }), { - target: { value: 'test@example.com' } - }) - fireEvent.click(getByRole('button', { name: 'Check' })) - - await waitFor(() => { - expect(mockEnqueueSnackbar).toHaveBeenCalled() - }) - - // Step 1 logs must still be visible despite step 2 failing - expect( - (getByRole('textbox', { name: 'Logs' }) as HTMLTextAreaElement).value - ).toContain('User found: test@example.com') - }) - }) - - describe('delete flow', () => { - async function runCheck( - getByRole: ReturnType['getByRole'] - ): Promise { - mockCheckMutate.mockResolvedValue({ - data: { - userDeleteCheck: { - userId: 'firebase-uid-1', - userEmail: 'test@example.com', - userFirstName: 'Test', - logs: [ - { - message: 'User found', - level: 'info', - timestamp: '2024-01-01T00:00:00.000Z' - } - ] - } - } - }) - mockJourneysCheckMutate.mockResolvedValue({ - data: { - userDeleteJourneysCheck: { - journeysToDelete: 0, - journeysToTransfer: 0, - journeysToRemove: 0, - teamsToDelete: 0, - teamsToTransfer: 0, - teamsToRemove: 0, - logs: [ - { - message: 'No journeys to delete', - level: 'info', - timestamp: '2024-01-01T00:00:01.000Z' - } - ] - } - } - }) - - fireEvent.change(getByRole('textbox', { name: 'User email to delete' }), { - target: { value: 'test@example.com' } - }) - fireEvent.click(getByRole('button', { name: 'Check' })) - - await waitFor(() => { - expect(getByRole('button', { name: 'Delete User' })).not.toBeDisabled() - }) - } - - it('shows success snackbar after full happy path deletion', async () => { - let capturedOnData: ((opts: unknown) => void) | null = null - mockUseSubscription.mockImplementation( - (_doc: unknown, opts: { onData?: (o: unknown) => void }) => { - if (opts?.onData != null) capturedOnData = opts.onData - return {} - } - ) - - mockJourneysConfirmMutate.mockResolvedValue({ - data: { - userDeleteJourneysConfirm: { - success: true, - deletedJourneyIds: ['j1'], - deletedTeamIds: [], - deletedUserJourneyIds: ['uj1'], - deletedUserTeamIds: [], - logs: [ - { - message: 'Journeys deleted successfully', - level: 'info', - timestamp: '2024-01-01T00:00:02.000Z' - } - ] - } - } - }) - - const { getByRole, getByText } = renderComponent() - await runCheck(getByRole) - - fireEvent.click(getByRole('button', { name: 'Delete User' })) - await waitFor(() => - expect(getByText('Confirm User Deletion')).toBeInTheDocument() - ) - fireEvent.click(getByRole('button', { name: 'Delete Permanently' })) - - await waitFor(() => { - expect(mockJourneysConfirmMutate).toHaveBeenCalledWith({ - variables: { userId: 'firebase-uid-1' } - }) - }) - - // Simulate subscription emitting completion - act(() => { - capturedOnData?.({ - data: { - data: { - userDeleteConfirm: { - log: { - message: 'User deleted successfully', - level: 'info', - timestamp: '2024-01-01T00:00:03.000Z' - }, - done: true, - success: true - } - } - } - }) - }) - - await waitFor(() => { - expect(mockEnqueueSnackbar).toHaveBeenCalledWith( - 'User deleted successfully', - { - variant: 'success' - } - ) - }) - }) - - it('shows error snackbar when journeys confirm returns failure', async () => { - mockJourneysConfirmMutate.mockResolvedValue({ - data: { - userDeleteJourneysConfirm: { - success: false, - deletedJourneyIds: [], - deletedTeamIds: [], - deletedUserJourneyIds: [], - deletedUserTeamIds: [], - logs: [ - { - message: 'Failed to delete journeys', - level: 'error', - timestamp: '2024-01-01T00:00:02.000Z' - } - ] - } - } - }) - - const { getByRole, getByText } = renderComponent() - await runCheck(getByRole) - - fireEvent.click(getByRole('button', { name: 'Delete User' })) - await waitFor(() => - expect(getByText('Confirm User Deletion')).toBeInTheDocument() - ) - fireEvent.click(getByRole('button', { name: 'Delete Permanently' })) - - await waitFor(() => { - expect(mockEnqueueSnackbar).toHaveBeenCalledWith( - 'Journeys cleanup failed. Check logs for details.', - { variant: 'error' } - ) - }) - }) - }) }) diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx index ebe650dce45..0721b0fc463 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx @@ -9,7 +9,6 @@ import Alert from '@mui/material/Alert' import AlertTitle from '@mui/material/AlertTitle' import Box from '@mui/material/Box' import Button from '@mui/material/Button' -import CircularProgress from '@mui/material/CircularProgress' import Dialog from '@mui/material/Dialog' import DialogActions from '@mui/material/DialogActions' import DialogContent from '@mui/material/DialogContent' @@ -30,9 +29,9 @@ import { ErrorInfo, ReactElement, ReactNode, - Suspense, useCallback, useEffect, + useMemo, useRef, useState } from 'react' @@ -47,14 +46,6 @@ import { UserDeleteConfirmSubscription, UserDeleteConfirmSubscriptionVariables } from '../../../__generated__/UserDeleteConfirmSubscription' -import { - UserDeleteJourneysCheck, - UserDeleteJourneysCheckVariables -} from '../../../__generated__/UserDeleteJourneysCheck' -import { - UserDeleteJourneysConfirm, - UserDeleteJourneysConfirmVariables -} from '../../../__generated__/UserDeleteJourneysConfirm' import { GET_ME } from '../PageWrapper/NavigationDrawer/UserNavigation/UserNavigation' interface LogEntry { @@ -69,18 +60,6 @@ export const USER_DELETE_CHECK = gql` userId userEmail userFirstName - logs { - message - level - timestamp - } - } - } -` - -export const USER_DELETE_JOURNEYS_CHECK = gql` - mutation UserDeleteJourneysCheck($userId: String!) { - userDeleteJourneysCheck(userId: $userId) { journeysToDelete journeysToTransfer journeysToRemove @@ -96,40 +75,12 @@ export const USER_DELETE_JOURNEYS_CHECK = gql` } ` -export const USER_DELETE_JOURNEYS_CONFIRM = gql` - mutation UserDeleteJourneysConfirm($userId: String!) { - userDeleteJourneysConfirm(userId: $userId) { - success - deletedJourneyIds - deletedTeamIds - deletedUserJourneyIds - deletedUserTeamIds - logs { - message - level - timestamp - } - } - } -` - export const USER_DELETE_CONFIRM = gql` subscription UserDeleteConfirmSubscription( $idType: UserDeleteIdType! $id: String! - $deletedJourneyIds: [String!]! - $deletedTeamIds: [String!]! - $deletedUserJourneyIds: [String!]! - $deletedUserTeamIds: [String!]! ) { - userDeleteConfirm( - idType: $idType - id: $id - deletedJourneyIds: $deletedJourneyIds - deletedTeamIds: $deletedTeamIds - deletedUserJourneyIds: $deletedUserJourneyIds - deletedUserTeamIds: $deletedUserTeamIds - ) { + userDeleteConfirm(idType: $idType, id: $id) { log { message level @@ -196,9 +147,7 @@ function UserDeleteErrorFallback({ export function UserDeleteWithErrorBoundary(): ReactElement { return ( - }> - - + ) } @@ -206,16 +155,6 @@ export function UserDeleteWithErrorBoundary(): ReactElement { interface ConfirmVars { idType: UserDeleteIdType id: string - deletedJourneyIds: string[] - deletedTeamIds: string[] - deletedUserJourneyIds: string[] - deletedUserTeamIds: string[] -} - -const levelLabel: Record = { - error: 'ERROR', - warn: 'WARN', - info: 'INFO' } function UserDeleteContent(): ReactElement { @@ -228,10 +167,10 @@ function UserDeleteContent(): ReactElement { const [idType, setIdType] = useState(UserDeleteIdType.email) const [userId, setUserId] = useState('') - const [resolvedUserId, setResolvedUserId] = useState('') const [logs, setLogs] = useState([]) const [checkComplete, setCheckComplete] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false) + const [deleteComplete, setDeleteComplete] = useState(false) const [confirmVars, setConfirmVars] = useState(null) const [userDeleteCheck, { loading: checkLoading }] = useMutation< @@ -239,29 +178,12 @@ function UserDeleteContent(): ReactElement { UserDeleteCheckVariables >(USER_DELETE_CHECK) - const [userDeleteJourneysCheck, { loading: journeysCheckLoading }] = - useMutation( - USER_DELETE_JOURNEYS_CHECK - ) - - const [userDeleteJourneysConfirm, { loading: journeysConfirmLoading }] = - useMutation( - USER_DELETE_JOURNEYS_CONFIRM - ) - useSubscription< UserDeleteConfirmSubscription, UserDeleteConfirmSubscriptionVariables >(USER_DELETE_CONFIRM, { skip: confirmVars == null, - variables: confirmVars ?? { - idType: UserDeleteIdType.email, - id: '', - deletedJourneyIds: [], - deletedTeamIds: [], - deletedUserJourneyIds: [], - deletedUserTeamIds: [] - }, + variables: confirmVars ?? { idType: UserDeleteIdType.email, id: '' }, onData: ({ data: subData }) => { const progress = subData.data?.userDeleteConfirm if (progress == null) return @@ -269,19 +191,19 @@ function UserDeleteContent(): ReactElement { setLogs((prev) => [...prev, progress.log]) if (progress.done) { + setDeleteComplete(true) setConfirmVars(null) if (progress.success === true) { enqueueSnackbar(t('User deleted successfully'), { variant: 'success' }) + setCheckComplete(false) } else { enqueueSnackbar(t('User deletion failed. Check logs for details.'), { variant: 'error' }) } - setCheckComplete(false) - setResolvedUserId('') } }, onError: (error) => { @@ -299,10 +221,7 @@ function UserDeleteContent(): ReactElement { } }) - // journeysConfirmLoading covers the period between clicking "Delete - // Permanently" and the subscription starting. - const confirmLoading = journeysConfirmLoading || confirmVars != null - const isCheckLoading = checkLoading || journeysCheckLoading + const confirmLoading = confirmVars != null && !deleteComplete const isSuperAdmin = data.me?.__typename === 'AuthenticatedUser' && data.me.superAdmin === true @@ -322,50 +241,39 @@ function UserDeleteContent(): ReactElement { } }, [logs]) - const logText = logs - .map((log) => { - const time = new Date(log.timestamp).toLocaleTimeString() - return `[${time}] ${levelLabel[log.level] ?? log.level.toUpperCase()}: ${log.message}` - }) - .join('\n') + const logText = useMemo( + () => + logs + .map((log) => { + const time = new Date(log.timestamp).toLocaleTimeString() + const prefix = + log.level === 'error' + ? 'ERROR' + : log.level === 'warn' + ? 'WARN' + : 'INFO' + return `[${time}] ${prefix}: ${log.message}` + }) + .join('\n'), + [logs] + ) const handleCheck = useCallback(async () => { if (userId.trim() === '') return setLogs([]) setCheckComplete(false) - setResolvedUserId('') + setDeleteComplete(false) try { - // Step 1: check user info from api-users const { data: checkData } = await userDeleteCheck({ variables: { idType, id: userId.trim() } }) - if (checkData?.userDeleteCheck == null) return - - const userLogs: LogEntry[] = checkData.userDeleteCheck.logs - const uid = checkData.userDeleteCheck.userId - - // Show Step 1 logs immediately so they're visible even if Step 2 fails - setLogs(userLogs) - setResolvedUserId(uid) - - // Step 2: check journeys counts from api-journeys-modern using the - // resolved Firebase UID returned by userDeleteCheck - if (uid !== '') { - const { data: journeysData } = await userDeleteJourneysCheck({ - variables: { userId: uid } - }) - if (journeysData?.userDeleteJourneysCheck != null) { - setLogs((prev) => [ - ...prev, - ...journeysData.userDeleteJourneysCheck.logs - ]) - } + if (checkData?.userDeleteCheck != null) { + setLogs(checkData.userDeleteCheck.logs) + setCheckComplete(true) } - - setCheckComplete(true) } catch (error) { if (error instanceof ApolloError) { const message = error.graphQLErrors[0]?.message ?? error.message @@ -378,123 +286,15 @@ function UserDeleteContent(): ReactElement { } ]) enqueueSnackbar(message, { variant: 'error', preventDuplicate: true }) - } else { - const message = - error instanceof Error - ? error.message - : t('An unexpected error occurred.') - setLogs((prev) => [ - ...prev, - { - message: `Error: ${message}`, - level: 'error', - timestamp: new Date().toISOString() - } - ]) - enqueueSnackbar(t('An unexpected error occurred.'), { - variant: 'error', - preventDuplicate: true - }) } } - }, [ - idType, - userId, - userDeleteCheck, - userDeleteJourneysCheck, - enqueueSnackbar, - t - ]) - - const handleConfirmDelete = useCallback(async () => { - setConfirmOpen(false) + }, [idType, userId, userDeleteCheck, enqueueSnackbar]) - if (resolvedUserId === '') { - const errMsg = t('An unexpected error occurred.') - setLogs((prev) => [ - ...prev, - { - message: `Error: ${errMsg}`, - level: 'error', - timestamp: new Date().toISOString() - } - ]) - enqueueSnackbar(errMsg, { variant: 'error', preventDuplicate: true }) - return - } - - try { - // Step 3: delete journeys data from api-journeys-modern, get back IDs - const journeysResult = await userDeleteJourneysConfirm({ - variables: { userId: resolvedUserId } - }) - - const journeysConfirmData = journeysResult.data?.userDeleteJourneysConfirm - if (journeysConfirmData == null) { - enqueueSnackbar(t('Journeys cleanup failed. Check logs for details.'), { - variant: 'error' - }) - return - } - - // Append journeys confirm logs - setLogs((prev) => [...prev, ...journeysConfirmData.logs]) - - if (!journeysConfirmData.success) { - enqueueSnackbar(t('Journeys cleanup failed. Check logs for details.'), { - variant: 'error' - }) - return - } - - // Step 4: start userDeleteConfirm subscription with deleted IDs - setConfirmVars({ - idType, - id: userId.trim(), - deletedJourneyIds: journeysConfirmData.deletedJourneyIds, - deletedTeamIds: journeysConfirmData.deletedTeamIds, - deletedUserJourneyIds: journeysConfirmData.deletedUserJourneyIds, - deletedUserTeamIds: journeysConfirmData.deletedUserTeamIds - }) - } catch (error) { - if (error instanceof ApolloError) { - const message = error.graphQLErrors[0]?.message ?? error.message - setLogs((prev) => [ - ...prev, - { - message: `Error: ${message}`, - level: 'error', - timestamp: new Date().toISOString() - } - ]) - enqueueSnackbar(message, { variant: 'error', preventDuplicate: true }) - } else { - const message = - error instanceof Error - ? error.message - : t('An unexpected error occurred.') - setLogs((prev) => [ - ...prev, - { - message: `Error: ${message}`, - level: 'error', - timestamp: new Date().toISOString() - } - ]) - enqueueSnackbar(t('An unexpected error occurred.'), { - variant: 'error', - preventDuplicate: true - }) - } - } - }, [ - idType, - userId, - resolvedUserId, - userDeleteJourneysConfirm, - enqueueSnackbar, - t - ]) + const handleConfirmDelete = useCallback(() => { + setConfirmOpen(false) + setDeleteComplete(false) + setConfirmVars({ idType, id: userId.trim() }) + }, [idType, userId]) if (!isSuperAdmin) return <> @@ -518,16 +318,15 @@ function UserDeleteContent(): ReactElement { {t('Lookup By')} { From 5674dd8d76952a55f344bc1f1c029fc9a2ac7557 Mon Sep 17 00:00:00 2001 From: Siyang Date: Mon, 30 Mar 2026 01:01:49 +0000 Subject: [PATCH 13/29] fix: keep logs after deletion --- apps/journeys-admin/src/components/UserDelete/UserDelete.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx index 41f187b480c..3b3dd512714 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx @@ -205,8 +205,6 @@ function UserDeleteContent(): ReactElement { variant: 'success' }) setCheckComplete(false) - setUserId('') - setLogs([]) } else { enqueueSnackbar(t('User deletion failed. Check logs for details.'), { variant: 'error' From 346917c8608c604441d34fc545d069b5d3f4ef82 Mon Sep 17 00:00:00 2001 From: Siyang Date: Mon, 30 Mar 2026 02:44:37 +0000 Subject: [PATCH 14/29] fix: clear auth cookie and redirect to sign-in when account is deleted Co-Authored-By: Claude Sonnet 4.6 --- apps/journeys-admin/pages/api/clear-auth.ts | 12 ++--- .../src/libs/apolloClient/apolloClient.ts | 4 +- .../checkConditionalRedirect.ts | 50 +++++-------------- 3 files changed, 19 insertions(+), 47 deletions(-) diff --git a/apps/journeys-admin/pages/api/clear-auth.ts b/apps/journeys-admin/pages/api/clear-auth.ts index d80474ced62..e1bb8467b0f 100644 --- a/apps/journeys-admin/pages/api/clear-auth.ts +++ b/apps/journeys-admin/pages/api/clear-auth.ts @@ -6,11 +6,9 @@ export default function handler( _req: NextApiRequest, res: NextApiResponse ): void { - const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '' - const base = `Path=/; HttpOnly; Max-Age=0; SameSite=Lax${secure}` - res.setHeader('Set-Cookie', [ - `${authConfig.cookieName}=; ${base}`, - `${authConfig.cookieName}.sig=; ${base}` - ]) - res.redirect(303, '/users/sign-in') + res.setHeader( + 'Set-Cookie', + `${authConfig.cookieName}=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax` + ) + res.redirect(307, '/users/sign-in') } diff --git a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts index 4b9b8189cc5..279254eb31e 100644 --- a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts +++ b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts @@ -128,10 +128,10 @@ export function createApolloClient( true ) { void logout() - // Propagate a settled error so any awaiting promise rejects cleanly + // Return an empty observable to suppress the error so React does not crash // while logout() clears the Firebase token and redirects to sign-in return new Observable((observer) => { - observer.error(new Error('Session expired')) + observer.complete() }) } }) diff --git a/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts b/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts index cf9eefcfa43..43d43064673 100644 --- a/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts +++ b/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts @@ -36,33 +36,24 @@ export async function checkConditionalRedirect({ teamName, allowGuest = false }: CheckConditionalRedirectProps): Promise { - const requestedRedirect = new URL( + const currentRedirect = new URL( resolvedUrl, 'https://admin.nextstep.is' ).searchParams.get('redirect') - const currentRedirect = - requestedRedirect != null && - requestedRedirect.startsWith('/') && - !requestedRedirect.startsWith('//') - ? requestedRedirect - : null - - let redirectForApi: string | undefined - let encodedRedirect = '' + let redirect = '' if (currentRedirect != null) { - redirectForApi = currentRedirect - encodedRedirect = `?redirect=${encodeURIComponent(currentRedirect)}` - } else if (resolvedUrl !== '/') { - redirectForApi = resolvedUrl - encodedRedirect = `?redirect=${encodeURIComponent(resolvedUrl)}` + redirect = `?redirect=${encodeURIComponent(currentRedirect)}` + } else { + if (resolvedUrl !== '/') + redirect = `?redirect=${encodeURIComponent(resolvedUrl)}` } let meResult: GetMe | undefined try { const { data } = await apolloClient.query({ query: GET_ME, - variables: { input: { redirect: redirectForApi } } + variables: { input: { redirect } } }) meResult = data } catch (error) { @@ -87,7 +78,7 @@ export async function checkConditionalRedirect({ if (!(me.me?.emailVerified ?? false)) { if (resolvedUrl.startsWith('/users/verify')) return return { - destination: `/users/verify${encodedRedirect}`, + destination: `/users/verify${redirect}`, permanent: false } } @@ -97,8 +88,8 @@ export async function checkConditionalRedirect({ return } - // don't redirect when already on /users/verify with the same redirect param - if (resolvedUrl.startsWith(`/users/verify${encodedRedirect}`)) return + // don't redirect on /users/verify + if (resolvedUrl.startsWith(`/users/verify${redirect}`)) return const { data } = await apolloClient.query({ query: GET_JOURNEY_PROFILE_AND_TEAMS @@ -107,24 +98,7 @@ export async function checkConditionalRedirect({ if (data.getJourneyProfile?.acceptedTermsAt == null) { if (resolvedUrl.startsWith('/users/terms-and-conditions')) return return { - destination: `/users/terms-and-conditions${encodedRedirect}`, - permanent: false - } - } - - // Terms already accepted — skip past the terms page if we're still on it - // (e.g. redirected here after email verification via link). - // Only forward the original ?redirect= destination, not the terms URL itself. - if (resolvedUrl.startsWith('/users/terms-and-conditions')) { - const forwardRedirect = currentRedirect != null ? encodedRedirect : '' - if (data.teams.length === 0) { - return { - destination: `/teams/new${forwardRedirect}`, - permanent: false - } - } - return { - destination: currentRedirect ?? '/', + destination: `/users/terms-and-conditions${redirect}`, permanent: false } } @@ -139,7 +113,7 @@ export async function checkConditionalRedirect({ } if (resolvedUrl.startsWith('/teams/new')) return return { - destination: `/teams/new${encodedRedirect}`, + destination: `/teams/new${redirect}`, permanent: false } } From e74c83451701b4f79ec568d5c0b787959cb612e8 Mon Sep 17 00:00:00 2001 From: Siyang Date: Tue, 14 Apr 2026 18:13:25 +1200 Subject: [PATCH 15/29] feat(NES-1455): remove interop token pattern, switch to isSuperAdmin auth, 4-step frontend delete flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **api-journeys-modern** - Add isSuperAdmin auth scope (queries api-users DB for superAdmin flag) to authScopes + builder - Switch userDeleteJourneysCheck and userDeleteJourneysConfirm from isValidInterop to isSuperAdmin so the frontend can call them directly via the gateway **api-users** - Remove journeys interop: delete journeysInterop.ts + spec, remove callJourneysCheck/callJourneysConfirm from service/index.ts - userDeleteCheck: strip journeys count fields from result, return user.userId (Firebase UID) so the frontend can pass it on to the journeys mutations - userDeleteConfirm subscription: add deletedJourneyIds/deletedTeamIds/deletedUserJourneyIds/deletedUserTeamIds args; use them directly in deleteUserData instead of calling journeys interop - Remove GATEWAY_URL from infrastructure/locals.tf (no longer needed now that api-users doesn't call the gateway) **apps/journeys-admin** - Update UserDelete component to 4-step flow: 1. userDeleteCheck → api-users (user info + Firebase UID) 2. userDeleteJourneysCheck → api-journeys-modern (journeys counts) 3. On confirm: userDeleteJourneysConfirm → api-journeys-modern (delete journeys data, get back deleted IDs) 4. userDeleteConfirm subscription → api-users (pass deleted IDs for audit log, delete user record + Firebase) - Update all generated types: remove journeys fields from UserDeleteCheck, add deleted ID vars to UserDeleteConfirmSubscription, add new UserDeleteJourneysCheck + UserDeleteJourneysConfirm types Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/UserDelete/UserDelete.tsx | 175 ++++++++++++++++-- 1 file changed, 161 insertions(+), 14 deletions(-) diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx index 3b3dd512714..22ba7fefbc0 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx @@ -46,6 +46,14 @@ import { UserDeleteConfirmSubscription, UserDeleteConfirmSubscriptionVariables } from '../../../__generated__/UserDeleteConfirmSubscription' +import { + UserDeleteJourneysCheck, + UserDeleteJourneysCheckVariables +} from '../../../__generated__/UserDeleteJourneysCheck' +import { + UserDeleteJourneysConfirm, + UserDeleteJourneysConfirmVariables +} from '../../../__generated__/UserDeleteJourneysConfirm' import { GET_ME } from '../PageWrapper/NavigationDrawer/UserNavigation/UserNavigation' interface LogEntry { @@ -60,6 +68,18 @@ export const USER_DELETE_CHECK = gql` userId userEmail userFirstName + logs { + message + level + timestamp + } + } + } +` + +export const USER_DELETE_JOURNEYS_CHECK = gql` + mutation UserDeleteJourneysCheck($userId: String!) { + userDeleteJourneysCheck(userId: $userId) { journeysToDelete journeysToTransfer journeysToRemove @@ -75,12 +95,40 @@ export const USER_DELETE_CHECK = gql` } ` +export const USER_DELETE_JOURNEYS_CONFIRM = gql` + mutation UserDeleteJourneysConfirm($userId: String!) { + userDeleteJourneysConfirm(userId: $userId) { + success + deletedJourneyIds + deletedTeamIds + deletedUserJourneyIds + deletedUserTeamIds + logs { + message + level + timestamp + } + } + } +` + export const USER_DELETE_CONFIRM = gql` subscription UserDeleteConfirmSubscription( $idType: UserDeleteIdType! $id: String! + $deletedJourneyIds: [String!]! + $deletedTeamIds: [String!]! + $deletedUserJourneyIds: [String!]! + $deletedUserTeamIds: [String!]! ) { - userDeleteConfirm(idType: $idType, id: $id) { + userDeleteConfirm( + idType: $idType + id: $id + deletedJourneyIds: $deletedJourneyIds + deletedTeamIds: $deletedTeamIds + deletedUserJourneyIds: $deletedUserJourneyIds + deletedUserTeamIds: $deletedUserTeamIds + ) { log { message level @@ -157,6 +205,10 @@ export function UserDeleteWithErrorBoundary(): ReactElement { interface ConfirmVars { idType: UserDeleteIdType id: string + deletedJourneyIds: string[] + deletedTeamIds: string[] + deletedUserJourneyIds: string[] + deletedUserTeamIds: string[] } const levelLabel: Record = { @@ -185,12 +237,29 @@ function UserDeleteContent(): ReactElement { UserDeleteCheckVariables >(USER_DELETE_CHECK) + const [userDeleteJourneysCheck, { loading: journeysCheckLoading }] = + useMutation( + USER_DELETE_JOURNEYS_CHECK + ) + + const [userDeleteJourneysConfirm, { loading: journeysConfirmLoading }] = + useMutation( + USER_DELETE_JOURNEYS_CONFIRM + ) + useSubscription< UserDeleteConfirmSubscription, UserDeleteConfirmSubscriptionVariables >(USER_DELETE_CONFIRM, { skip: confirmVars == null, - variables: confirmVars ?? { idType: UserDeleteIdType.email, id: '' }, + variables: confirmVars ?? { + idType: UserDeleteIdType.email, + id: '', + deletedJourneyIds: [], + deletedTeamIds: [], + deletedUserJourneyIds: [], + deletedUserTeamIds: [] + }, onData: ({ data: subData }) => { const progress = subData.data?.userDeleteConfirm if (progress == null) return @@ -227,7 +296,10 @@ function UserDeleteContent(): ReactElement { } }) - const confirmLoading = confirmVars != null + // journeysConfirmLoading covers the period between clicking "Delete + // Permanently" and the subscription starting. + const confirmLoading = journeysConfirmLoading || confirmVars != null + const isCheckLoading = checkLoading || journeysCheckLoading const isSuperAdmin = data.me?.__typename === 'AuthenticatedUser' && data.me.superAdmin === true @@ -261,14 +333,30 @@ function UserDeleteContent(): ReactElement { setCheckComplete(false) try { + // Step 1: check user info from api-users const { data: checkData } = await userDeleteCheck({ variables: { idType, id: userId.trim() } }) - if (checkData?.userDeleteCheck != null) { - setLogs(checkData.userDeleteCheck.logs) - setCheckComplete(true) + if (checkData?.userDeleteCheck == null) return + + const userLogs: LogEntry[] = checkData.userDeleteCheck.logs + const resolvedUserId = checkData.userDeleteCheck.userId + + // Step 2: check journeys counts from api-journeys-modern using the + // resolved Firebase UID returned by userDeleteCheck + let journeysLogs: LogEntry[] = [] + if (resolvedUserId !== '') { + const { data: journeysData } = await userDeleteJourneysCheck({ + variables: { userId: resolvedUserId } + }) + if (journeysData?.userDeleteJourneysCheck != null) { + journeysLogs = journeysData.userDeleteJourneysCheck.logs + } } + + setLogs([...userLogs, ...journeysLogs]) + setCheckComplete(true) } catch (error) { if (error instanceof ApolloError) { const message = error.graphQLErrors[0]?.message ?? error.message @@ -283,12 +371,71 @@ function UserDeleteContent(): ReactElement { enqueueSnackbar(message, { variant: 'error', preventDuplicate: true }) } } - }, [idType, userId, userDeleteCheck, enqueueSnackbar]) + }, [idType, userId, userDeleteCheck, userDeleteJourneysCheck, enqueueSnackbar]) - const handleConfirmDelete = useCallback(() => { + const handleConfirmDelete = useCallback(async () => { setConfirmOpen(false) - setConfirmVars({ idType, id: userId.trim() }) - }, [idType, userId]) + + try { + // Step 3: delete journeys data from api-journeys-modern, get back IDs + const { data: checkData } = await userDeleteCheck({ + variables: { idType, id: userId.trim() } + }) + const resolvedUserId = checkData?.userDeleteCheck?.userId ?? '' + + const journeysResult = await userDeleteJourneysConfirm({ + variables: { userId: resolvedUserId } + }) + + const journeysConfirmData = journeysResult.data?.userDeleteJourneysConfirm + if (journeysConfirmData == null) { + enqueueSnackbar(t('Journeys cleanup failed. Check logs for details.'), { + variant: 'error' + }) + return + } + + // Append journeys confirm logs + setLogs((prev) => [...prev, ...journeysConfirmData.logs]) + + if (!journeysConfirmData.success) { + enqueueSnackbar(t('Journeys cleanup failed. Check logs for details.'), { + variant: 'error' + }) + return + } + + // Step 4: start userDeleteConfirm subscription with deleted IDs + setConfirmVars({ + idType, + id: userId.trim(), + deletedJourneyIds: journeysConfirmData.deletedJourneyIds, + deletedTeamIds: journeysConfirmData.deletedTeamIds, + deletedUserJourneyIds: journeysConfirmData.deletedUserJourneyIds, + deletedUserTeamIds: journeysConfirmData.deletedUserTeamIds + }) + } catch (error) { + if (error instanceof ApolloError) { + const message = error.graphQLErrors[0]?.message ?? error.message + setLogs((prev) => [ + ...prev, + { + message: `Error: ${message}`, + level: 'error', + timestamp: new Date().toISOString() + } + ]) + enqueueSnackbar(message, { variant: 'error', preventDuplicate: true }) + } + } + }, [ + idType, + userId, + userDeleteCheck, + userDeleteJourneysConfirm, + enqueueSnackbar, + t + ]) if (!isSuperAdmin) return <> @@ -320,7 +467,7 @@ function UserDeleteContent(): ReactElement { setCheckComplete(false) setLogs([]) }} - disabled={checkLoading || confirmLoading} + disabled={isCheckLoading || confirmLoading} > {t('Email')} @@ -350,17 +497,17 @@ function UserDeleteContent(): ReactElement { onKeyDown={(e) => { if (e.key === 'Enter') void handleCheck() }} - disabled={checkLoading || confirmLoading} + disabled={isCheckLoading || confirmLoading} fullWidth /> From 5adee27c523e4872cc31b36b9752547c3d258063 Mon Sep 17 00:00:00 2001 From: Siyang Date: Tue, 14 Apr 2026 18:46:10 +1200 Subject: [PATCH 16/29] fix: address review findings in UserDelete flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make isSuperAdmin lazy in api-journeys-modern authScopes — moves the prismaUsers DB roundtrip into a function so it only executes when the scope is actually checked, not on every authenticated request - Store resolvedUserId in state from the check step so handleConfirmDelete no longer re-calls userDeleteCheck (eliminates the uncovered loading window that could allow concurrent deletions) - Guard handleConfirmDelete against empty resolvedUserId with early return - Show Step 1 logs immediately before entering Step 2 so they're preserved if the journeys check fails - Add else branches in handleCheck and handleConfirmDelete catch blocks so non-ApolloError exceptions surface as log entries and snackbar messages - Wrap UserDelete.spec.tsx renders in ThemeProvider and SnackbarProvider per journeys-admin test conventions - Add missing i18n key 'Journeys cleanup failed. Check logs for details.' - Add trailing newlines to api-users and api-journeys-modern schema.graphql Co-Authored-By: Claude Sonnet 4.6 --- apis/api-journeys-modern/schema.graphql | 2 +- .../components/UserDelete/UserDelete.spec.tsx | 59 ++++++--------- .../src/components/UserDelete/UserDelete.tsx | 71 +++++++++++++++---- 3 files changed, 82 insertions(+), 50 deletions(-) diff --git a/apis/api-journeys-modern/schema.graphql b/apis/api-journeys-modern/schema.graphql index b06c5051157..db7883efd4f 100644 --- a/apis/api-journeys-modern/schema.graphql +++ b/apis/api-journeys-modern/schema.graphql @@ -2872,4 +2872,4 @@ type YouTube id: ID! @external primaryLanguageId: ID @external source: VideoBlockSource! @shareable -} \ No newline at end of file +} diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx index cdea3a2fecc..e7d93cf83c9 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx @@ -1,5 +1,8 @@ import { MockedProvider } from '@apollo/client/testing' import { render } from '@testing-library/react' +import { SnackbarProvider } from 'notistack' + +import { ThemeProvider } from '../ThemeProvider' import { UserDeleteWithErrorBoundary } from './UserDelete' @@ -15,7 +18,8 @@ jest.mock('next/router', () => ({ jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: jest.fn() - }) + }), + SnackbarProvider: ({ children }: { children: React.ReactNode }) => <>{children} })) const mockUseSuspenseQuery = jest.fn() @@ -27,6 +31,17 @@ jest.mock('@apollo/client', () => { } }) +const renderComponent = (): ReturnType => + render( + + + + + + + + ) + describe('UserDeleteWithErrorBoundary', () => { beforeEach(() => { jest.clearAllMocks() @@ -42,11 +57,7 @@ describe('UserDeleteWithErrorBoundary', () => { }) it('should render the form for superAdmin users', () => { - const { getAllByText, getByText } = render( - - - - ) + const { getAllByText, getByText } = renderComponent() expect(getAllByText('Delete User').length).toBeGreaterThanOrEqual(1) expect(getByText('Check')).toBeInTheDocument() @@ -64,11 +75,7 @@ describe('UserDeleteWithErrorBoundary', () => { } }) - render( - - - - ) + renderComponent() expect(mockPush).toHaveBeenCalledWith('/') }) @@ -84,21 +91,13 @@ describe('UserDeleteWithErrorBoundary', () => { } }) - const { queryByText } = render( - - - - ) + const { queryByText } = renderComponent() expect(queryByText('Check')).not.toBeInTheDocument() }) it('should have delete button disabled before check', () => { - const { getAllByRole } = render( - - - - ) + const { getAllByRole } = renderComponent() const deleteUserButtons = getAllByRole('button', { name: 'Delete User' }) const actionBtn = deleteUserButtons[deleteUserButtons.length - 1] @@ -106,31 +105,19 @@ describe('UserDeleteWithErrorBoundary', () => { }) it('should have check button disabled when input is empty', () => { - const { getByText } = render( - - - - ) + const { getByText } = renderComponent() expect(getByText('Check').closest('button')).toBeDisabled() }) it('should render lookup type selector with email as default', () => { - const { getByLabelText } = render( - - - - ) + const { getByLabelText } = renderComponent() expect(getByLabelText('Lookup By')).toBeInTheDocument() }) it('should render logs textfield', () => { - const { getByRole } = render( - - - - ) + const { getByRole } = renderComponent() const logsField = getByRole('textbox', { name: 'Logs' }) expect(logsField).toBeInTheDocument() diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx index 22ba7fefbc0..694ddc388eb 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx @@ -227,6 +227,7 @@ function UserDeleteContent(): ReactElement { const [idType, setIdType] = useState(UserDeleteIdType.email) const [userId, setUserId] = useState('') + const [resolvedUserId, setResolvedUserId] = useState('') const [logs, setLogs] = useState([]) const [checkComplete, setCheckComplete] = useState(false) const [confirmOpen, setConfirmOpen] = useState(false) @@ -274,6 +275,7 @@ function UserDeleteContent(): ReactElement { variant: 'success' }) setCheckComplete(false) + setResolvedUserId('') } else { enqueueSnackbar(t('User deletion failed. Check logs for details.'), { variant: 'error' @@ -331,6 +333,7 @@ function UserDeleteContent(): ReactElement { setLogs([]) setCheckComplete(false) + setResolvedUserId('') try { // Step 1: check user info from api-users @@ -341,21 +344,26 @@ function UserDeleteContent(): ReactElement { if (checkData?.userDeleteCheck == null) return const userLogs: LogEntry[] = checkData.userDeleteCheck.logs - const resolvedUserId = checkData.userDeleteCheck.userId + const uid = checkData.userDeleteCheck.userId + + // Show Step 1 logs immediately so they're visible even if Step 2 fails + setLogs(userLogs) + setResolvedUserId(uid) // Step 2: check journeys counts from api-journeys-modern using the // resolved Firebase UID returned by userDeleteCheck - let journeysLogs: LogEntry[] = [] - if (resolvedUserId !== '') { + if (uid !== '') { const { data: journeysData } = await userDeleteJourneysCheck({ - variables: { userId: resolvedUserId } + variables: { userId: uid } }) if (journeysData?.userDeleteJourneysCheck != null) { - journeysLogs = journeysData.userDeleteJourneysCheck.logs + setLogs((prev) => [ + ...prev, + ...journeysData.userDeleteJourneysCheck.logs + ]) } } - setLogs([...userLogs, ...journeysLogs]) setCheckComplete(true) } catch (error) { if (error instanceof ApolloError) { @@ -369,20 +377,40 @@ function UserDeleteContent(): ReactElement { } ]) enqueueSnackbar(message, { variant: 'error', preventDuplicate: true }) + } else { + const message = + error instanceof Error ? error.message : t('An unexpected error occurred.') + setLogs((prev) => [ + ...prev, + { + message: `Error: ${message}`, + level: 'error', + timestamp: new Date().toISOString() + } + ]) + enqueueSnackbar(t('An unexpected error occurred.'), { + variant: 'error', + preventDuplicate: true + }) } } - }, [idType, userId, userDeleteCheck, userDeleteJourneysCheck, enqueueSnackbar]) + }, [idType, userId, userDeleteCheck, userDeleteJourneysCheck, enqueueSnackbar, t]) const handleConfirmDelete = useCallback(async () => { setConfirmOpen(false) + if (resolvedUserId === '') { + const errMsg = t('An unexpected error occurred.') + setLogs((prev) => [ + ...prev, + { message: `Error: ${errMsg}`, level: 'error', timestamp: new Date().toISOString() } + ]) + enqueueSnackbar(errMsg, { variant: 'error', preventDuplicate: true }) + return + } + try { // Step 3: delete journeys data from api-journeys-modern, get back IDs - const { data: checkData } = await userDeleteCheck({ - variables: { idType, id: userId.trim() } - }) - const resolvedUserId = checkData?.userDeleteCheck?.userId ?? '' - const journeysResult = await userDeleteJourneysConfirm({ variables: { userId: resolvedUserId } }) @@ -426,12 +454,27 @@ function UserDeleteContent(): ReactElement { } ]) enqueueSnackbar(message, { variant: 'error', preventDuplicate: true }) + } else { + const message = + error instanceof Error ? error.message : t('An unexpected error occurred.') + setLogs((prev) => [ + ...prev, + { + message: `Error: ${message}`, + level: 'error', + timestamp: new Date().toISOString() + } + ]) + enqueueSnackbar(t('An unexpected error occurred.'), { + variant: 'error', + preventDuplicate: true + }) } } }, [ idType, userId, - userDeleteCheck, + resolvedUserId, userDeleteJourneysConfirm, enqueueSnackbar, t @@ -465,6 +508,7 @@ function UserDeleteContent(): ReactElement { onChange={(e) => { setIdType(e.target.value as UserDeleteIdType) setCheckComplete(false) + setResolvedUserId('') setLogs([]) }} disabled={isCheckLoading || confirmLoading} @@ -493,6 +537,7 @@ function UserDeleteContent(): ReactElement { onChange={(e) => { setUserId(e.target.value) setCheckComplete(false) + setResolvedUserId('') }} onKeyDown={(e) => { if (e.key === 'Enter') void handleCheck() From 32f8b32a8d4c6a1c23f8caf24adbf27724338c16 Mon Sep 17 00:00:00 2001 From: Siyang Date: Tue, 14 Apr 2026 19:04:13 +1200 Subject: [PATCH 17/29] fix: address second round of review findings in UserDelete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add server-side superAdmin guard to pages/users/delete.tsx getServerSideProps by querying GET_ME with network-only; redirects non-admins to home before the page renders - Fix clear-auth.ts Set-Cookie: add Secure flag in production and clear the .sig signature cookie alongside the primary auth cookie - Fix apolloClient UNAUTHENTICATED handler: use observer.error() instead of observer.complete() so awaiting promises settle instead of hanging - Add trust-boundary comment in userDeleteConfirm.ts near the client-supplied deletedJourneyIds args to document audit log integrity considerations - Add integration tests for the 4-step delete flow: happy path (check → confirm → subscription completion → success snackbar) and error path (journeys confirm returns success:false → error snackbar); also adds a test verifying step 1 logs are preserved when step 2 fails Co-Authored-By: Claude Sonnet 4.6 --- apps/journeys-admin/pages/api/clear-auth.ts | 10 +- apps/journeys-admin/pages/users/delete.tsx | 17 +- .../components/UserDelete/UserDelete.spec.tsx | 309 +++++++++++++++++- .../src/libs/apolloClient/apolloClient.ts | 4 +- 4 files changed, 317 insertions(+), 23 deletions(-) diff --git a/apps/journeys-admin/pages/api/clear-auth.ts b/apps/journeys-admin/pages/api/clear-auth.ts index e1bb8467b0f..42353a4120f 100644 --- a/apps/journeys-admin/pages/api/clear-auth.ts +++ b/apps/journeys-admin/pages/api/clear-auth.ts @@ -6,9 +6,11 @@ export default function handler( _req: NextApiRequest, res: NextApiResponse ): void { - res.setHeader( - 'Set-Cookie', - `${authConfig.cookieName}=; Path=/; HttpOnly; Max-Age=0; SameSite=Lax` - ) + const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '' + const base = `Path=/; HttpOnly; Max-Age=0; SameSite=Lax${secure}` + res.setHeader('Set-Cookie', [ + `${authConfig.cookieName}=; ${base}`, + `${authConfig.cookieName}.sig=; ${base}` + ]) res.redirect(307, '/users/sign-in') } diff --git a/apps/journeys-admin/pages/users/delete.tsx b/apps/journeys-admin/pages/users/delete.tsx index 8bbd2180d70..255bd9da02b 100644 --- a/apps/journeys-admin/pages/users/delete.tsx +++ b/apps/journeys-admin/pages/users/delete.tsx @@ -3,8 +3,10 @@ import { useTranslation } from 'next-i18next' import { NextSeo } from 'next-seo' import { ReactElement } from 'react' +import { GetMe, GetMeVariables } from '../../__generated__/GetMe' import { PageWrapper } from '../../src/components/PageWrapper' import { UserDelete } from '../../src/components/UserDelete' +import { GET_ME } from '../../src/components/PageWrapper/NavigationDrawer/UserNavigation/UserNavigation' import { useAuth } from '../../src/libs/auth' import { getAuthTokens, @@ -32,7 +34,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { if (tokens == null) return redirectToLogin(ctx) const user = toUser(tokens) - const { redirect, translations } = await initAndAuthApp({ + const { apolloClient, redirect, translations } = await initAndAuthApp({ user, locale: ctx.locale, resolvedUrl: ctx.resolvedUrl @@ -40,6 +42,19 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { if (redirect != null) return { redirect } + // Server-side superAdmin guard — the client-side component also checks this, + // but we enforce it here to prevent unauthorised users from loading the page. + const meResult = await apolloClient.query({ + query: GET_ME, + fetchPolicy: 'network-only' + }) + if ( + meResult.data?.me?.__typename !== 'AuthenticatedUser' || + meResult.data.me.superAdmin !== true + ) { + return { redirect: { permanent: false, destination: '/' } } + } + return { props: { userSerialized: JSON.stringify(user), diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx index e7d93cf83c9..fb234650a43 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx @@ -1,5 +1,6 @@ +import { ApolloError } from '@apollo/client' import { MockedProvider } from '@apollo/client/testing' -import { render } from '@testing-library/react' +import { act, fireEvent, render, waitFor } from '@testing-library/react' import { SnackbarProvider } from 'notistack' import { ThemeProvider } from '../ThemeProvider' @@ -15,22 +16,54 @@ jest.mock('next/router', () => ({ }) })) +const mockEnqueueSnackbar = jest.fn() jest.mock('notistack', () => ({ useSnackbar: () => ({ - enqueueSnackbar: jest.fn() + enqueueSnackbar: mockEnqueueSnackbar }), SnackbarProvider: ({ children }: { children: React.ReactNode }) => <>{children} })) const mockUseSuspenseQuery = jest.fn() +const mockUseMutation = jest.fn() +const mockUseSubscription = jest.fn() + jest.mock('@apollo/client', () => { const actual = jest.requireActual('@apollo/client') return { ...actual, - useSuspenseQuery: (...args: unknown[]) => mockUseSuspenseQuery(...args) + useSuspenseQuery: (...args: unknown[]) => mockUseSuspenseQuery(...args), + useMutation: (...args: unknown[]) => mockUseMutation(...args), + useSubscription: (...args: unknown[]) => mockUseSubscription(...args) } }) +const mockCheckMutate = jest.fn() +const mockJourneysCheckMutate = jest.fn() +const mockJourneysConfirmMutate = jest.fn() + +function getOperationName(doc: unknown): string { + return (doc as any)?.definitions?.[0]?.name?.value ?? '' +} + +function setupMutations(): void { + mockUseMutation.mockImplementation((doc: unknown) => { + const name = getOperationName(doc) + if (name === 'UserDeleteJourneysCheck') + return [mockJourneysCheckMutate, { loading: false }] + if (name === 'UserDeleteJourneysConfirm') + return [mockJourneysConfirmMutate, { loading: false }] + return [mockCheckMutate, { loading: false }] + }) + mockUseSubscription.mockReturnValue({}) +} + +const superAdminData = { + data: { + me: { __typename: 'AuthenticatedUser', id: 'user-1', superAdmin: true } + } +} + const renderComponent = (): ReturnType => render( @@ -45,15 +78,8 @@ const renderComponent = (): ReturnType => describe('UserDeleteWithErrorBoundary', () => { beforeEach(() => { jest.clearAllMocks() - mockUseSuspenseQuery.mockReturnValue({ - data: { - me: { - __typename: 'AuthenticatedUser', - id: 'user-1', - superAdmin: true - } - } - }) + mockUseSuspenseQuery.mockReturnValue(superAdminData) + setupMutations() }) it('should render the form for superAdmin users', () => { @@ -97,11 +123,9 @@ describe('UserDeleteWithErrorBoundary', () => { }) it('should have delete button disabled before check', () => { - const { getAllByRole } = renderComponent() + const { getByRole } = renderComponent() - const deleteUserButtons = getAllByRole('button', { name: 'Delete User' }) - const actionBtn = deleteUserButtons[deleteUserButtons.length - 1] - expect(actionBtn).toBeDisabled() + expect(getByRole('button', { name: 'Delete User' })).toBeDisabled() }) it('should have check button disabled when input is empty', () => { @@ -123,4 +147,257 @@ describe('UserDeleteWithErrorBoundary', () => { expect(logsField).toBeInTheDocument() expect(logsField).toHaveAttribute('readonly') }) + + describe('check flow', () => { + it('shows logs from both steps and enables delete button on success', async () => { + mockCheckMutate.mockResolvedValue({ + data: { + userDeleteCheck: { + userId: 'firebase-uid-1', + userEmail: 'test@example.com', + userFirstName: 'Test', + logs: [ + { + message: 'User found: test@example.com', + level: 'info', + timestamp: '2024-01-01T00:00:00.000Z' + } + ] + } + } + }) + mockJourneysCheckMutate.mockResolvedValue({ + data: { + userDeleteJourneysCheck: { + journeysToDelete: 2, + journeysToTransfer: 0, + journeysToRemove: 0, + teamsToDelete: 1, + teamsToTransfer: 0, + teamsToRemove: 0, + logs: [ + { + message: 'Found 2 journeys to delete', + level: 'info', + timestamp: '2024-01-01T00:00:01.000Z' + } + ] + } + } + }) + + const { getByRole } = renderComponent() + + fireEvent.change(getByRole('textbox', { name: 'User email to delete' }), { + target: { value: 'test@example.com' } + }) + fireEvent.click(getByRole('button', { name: 'Check' })) + + await waitFor(() => { + expect(getByRole('textbox', { name: 'Logs' })).toHaveValue( + expect.stringContaining('User found: test@example.com') + ) + }) + + expect(getByRole('textbox', { name: 'Logs' })).toHaveValue( + expect.stringContaining('Found 2 journeys to delete') + ) + expect(getByRole('button', { name: 'Delete User' })).not.toBeDisabled() + }) + + it('preserves step 1 logs when step 2 fails', async () => { + mockCheckMutate.mockResolvedValue({ + data: { + userDeleteCheck: { + userId: 'firebase-uid-1', + userEmail: 'test@example.com', + userFirstName: 'Test', + logs: [ + { + message: 'User found: test@example.com', + level: 'info', + timestamp: '2024-01-01T00:00:00.000Z' + } + ] + } + } + }) + mockJourneysCheckMutate.mockRejectedValue( + new ApolloError({ errorMessage: 'journeys service unavailable' }) + ) + + const { getByRole } = renderComponent() + + fireEvent.change(getByRole('textbox', { name: 'User email to delete' }), { + target: { value: 'test@example.com' } + }) + fireEvent.click(getByRole('button', { name: 'Check' })) + + await waitFor(() => { + expect(mockEnqueueSnackbar).toHaveBeenCalled() + }) + + // Step 1 logs must still be visible despite step 2 failing + expect(getByRole('textbox', { name: 'Logs' })).toHaveValue( + expect.stringContaining('User found: test@example.com') + ) + }) + }) + + describe('delete flow', () => { + async function runCheck( + getByRole: ReturnType['getByRole'] + ): Promise { + mockCheckMutate.mockResolvedValue({ + data: { + userDeleteCheck: { + userId: 'firebase-uid-1', + userEmail: 'test@example.com', + userFirstName: 'Test', + logs: [ + { + message: 'User found', + level: 'info', + timestamp: '2024-01-01T00:00:00.000Z' + } + ] + } + } + }) + mockJourneysCheckMutate.mockResolvedValue({ + data: { + userDeleteJourneysCheck: { + journeysToDelete: 0, + journeysToTransfer: 0, + journeysToRemove: 0, + teamsToDelete: 0, + teamsToTransfer: 0, + teamsToRemove: 0, + logs: [ + { + message: 'No journeys to delete', + level: 'info', + timestamp: '2024-01-01T00:00:01.000Z' + } + ] + } + } + }) + + fireEvent.change(getByRole('textbox', { name: 'User email to delete' }), { + target: { value: 'test@example.com' } + }) + fireEvent.click(getByRole('button', { name: 'Check' })) + + await waitFor(() => { + expect(getByRole('button', { name: 'Delete User' })).not.toBeDisabled() + }) + } + + it('shows success snackbar after full happy path deletion', async () => { + let capturedOnData: ((opts: unknown) => void) | null = null + mockUseSubscription.mockImplementation( + (_doc: unknown, opts: { onData?: (o: unknown) => void }) => { + if (opts?.onData != null) capturedOnData = opts.onData + return {} + } + ) + + mockJourneysConfirmMutate.mockResolvedValue({ + data: { + userDeleteJourneysConfirm: { + success: true, + deletedJourneyIds: ['j1'], + deletedTeamIds: [], + deletedUserJourneyIds: ['uj1'], + deletedUserTeamIds: [], + logs: [ + { + message: 'Journeys deleted successfully', + level: 'info', + timestamp: '2024-01-01T00:00:02.000Z' + } + ] + } + } + }) + + const { getByRole, getByText } = renderComponent() + await runCheck(getByRole) + + fireEvent.click(getByRole('button', { name: 'Delete User' })) + await waitFor(() => + expect(getByText('Confirm User Deletion')).toBeInTheDocument() + ) + fireEvent.click(getByRole('button', { name: 'Delete Permanently' })) + + await waitFor(() => { + expect(mockJourneysConfirmMutate).toHaveBeenCalledWith({ + variables: { userId: 'firebase-uid-1' } + }) + }) + + // Simulate subscription emitting completion + act(() => { + capturedOnData?.({ + data: { + data: { + userDeleteConfirm: { + log: { + message: 'User deleted successfully', + level: 'info', + timestamp: '2024-01-01T00:00:03.000Z' + }, + done: true, + success: true + } + } + } + }) + }) + + await waitFor(() => { + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('User deleted successfully', { + variant: 'success' + }) + }) + }) + + it('shows error snackbar when journeys confirm returns failure', async () => { + mockJourneysConfirmMutate.mockResolvedValue({ + data: { + userDeleteJourneysConfirm: { + success: false, + deletedJourneyIds: [], + deletedTeamIds: [], + deletedUserJourneyIds: [], + deletedUserTeamIds: [], + logs: [ + { + message: 'Failed to delete journeys', + level: 'error', + timestamp: '2024-01-01T00:00:02.000Z' + } + ] + } + } + }) + + const { getByRole, getByText } = renderComponent() + await runCheck(getByRole) + + fireEvent.click(getByRole('button', { name: 'Delete User' })) + await waitFor(() => + expect(getByText('Confirm User Deletion')).toBeInTheDocument() + ) + fireEvent.click(getByRole('button', { name: 'Delete Permanently' })) + + await waitFor(() => { + expect(mockEnqueueSnackbar).toHaveBeenCalledWith( + 'Journeys cleanup failed. Check logs for details.', + { variant: 'error' } + ) + }) + }) + }) }) diff --git a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts index 279254eb31e..4b9b8189cc5 100644 --- a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts +++ b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts @@ -128,10 +128,10 @@ export function createApolloClient( true ) { void logout() - // Return an empty observable to suppress the error so React does not crash + // Propagate a settled error so any awaiting promise rejects cleanly // while logout() clears the Firebase token and redirects to sign-in return new Observable((observer) => { - observer.complete() + observer.error(new Error('Session expired')) }) } }) From f292849878cc082cee287f4afa09f3816d6153ae Mon Sep 17 00:00:00 2001 From: Siyang Date: Tue, 14 Apr 2026 19:16:22 +1200 Subject: [PATCH 18/29] fix: use 303 redirect in clear-auth and reset check state on deletion failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change 307→303 in clear-auth.ts so the redirect always collapses to GET, preventing a POST caller from being forwarded as POST to /users/sign-in - Move setCheckComplete(false)/setResolvedUserId('') outside the success branch so they run on both success and failure; prevents the Delete User button re-enabling after a partial failure and allowing a retry of step 3 (userDeleteJourneysConfirm) on an already partially-deleted user Co-Authored-By: Claude Sonnet 4.6 --- apps/journeys-admin/pages/api/clear-auth.ts | 2 +- apps/journeys-admin/src/components/UserDelete/UserDelete.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/journeys-admin/pages/api/clear-auth.ts b/apps/journeys-admin/pages/api/clear-auth.ts index 42353a4120f..d80474ced62 100644 --- a/apps/journeys-admin/pages/api/clear-auth.ts +++ b/apps/journeys-admin/pages/api/clear-auth.ts @@ -12,5 +12,5 @@ export default function handler( `${authConfig.cookieName}=; ${base}`, `${authConfig.cookieName}.sig=; ${base}` ]) - res.redirect(307, '/users/sign-in') + res.redirect(303, '/users/sign-in') } diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx index 694ddc388eb..cc38780e953 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx @@ -274,13 +274,13 @@ function UserDeleteContent(): ReactElement { enqueueSnackbar(t('User deleted successfully'), { variant: 'success' }) - setCheckComplete(false) - setResolvedUserId('') } else { enqueueSnackbar(t('User deletion failed. Check logs for details.'), { variant: 'error' }) } + setCheckComplete(false) + setResolvedUserId('') } }, onError: (error) => { From 891ef63ed9a5573531888274d307fcc8b69d2902 Mon Sep 17 00:00:00 2001 From: Siyang Date: Tue, 14 Apr 2026 19:16:56 +1200 Subject: [PATCH 19/29] fix: add Suspense fallback and void async onClick handlers in UserDelete - Add CircularProgress fallback to Suspense so GET_ME loading shows a spinner instead of a blank area - Wrap async handleCheck and handleConfirmDelete in void arrow functions for consistency with the onKeyDown handler Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/UserDelete/UserDelete.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx index cc38780e953..7c426ac80f9 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx @@ -5,6 +5,7 @@ import { useSubscription, useSuspenseQuery } from '@apollo/client' +import CircularProgress from '@mui/material/CircularProgress' import Alert from '@mui/material/Alert' import AlertTitle from '@mui/material/AlertTitle' import Box from '@mui/material/Box' @@ -195,7 +196,7 @@ function UserDeleteErrorFallback({ export function UserDeleteWithErrorBoundary(): ReactElement { return ( - + }> @@ -548,7 +549,7 @@ function UserDeleteContent(): ReactElement { - - - + onConfirm={() => void handleConfirmDelete()} + /> ) } diff --git a/apps/journeys-admin/src/components/UserDelete/UserDeleteConfirmDialog.tsx b/apps/journeys-admin/src/components/UserDelete/UserDeleteConfirmDialog.tsx new file mode 100644 index 00000000000..f4609002333 --- /dev/null +++ b/apps/journeys-admin/src/components/UserDelete/UserDeleteConfirmDialog.tsx @@ -0,0 +1,46 @@ +import Button from '@mui/material/Button' +import Dialog from '@mui/material/Dialog' +import DialogActions from '@mui/material/DialogActions' +import DialogContent from '@mui/material/DialogContent' +import DialogContentText from '@mui/material/DialogContentText' +import DialogTitle from '@mui/material/DialogTitle' +import { useTranslation } from 'next-i18next' +import { ReactElement } from 'react' + +interface UserDeleteConfirmDialogProps { + open: boolean + onClose: () => void + onConfirm: () => void +} + +export function UserDeleteConfirmDialog({ + open, + onClose, + onConfirm +}: UserDeleteConfirmDialogProps): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + return ( + + + {t('Confirm User Deletion')} + + + + {t( + 'Are you sure you want to permanently delete this user? This action cannot be undone. All associated data including journeys, team memberships, and related records will be permanently removed.' + )} + + + + + + + + ) +} diff --git a/apps/journeys-admin/src/components/UserDelete/UserDeleteErrorBoundary.tsx b/apps/journeys-admin/src/components/UserDelete/UserDeleteErrorBoundary.tsx new file mode 100644 index 00000000000..8878578ba04 --- /dev/null +++ b/apps/journeys-admin/src/components/UserDelete/UserDeleteErrorBoundary.tsx @@ -0,0 +1,63 @@ +import Alert from '@mui/material/Alert' +import AlertTitle from '@mui/material/AlertTitle' +import Box from '@mui/material/Box' +import Typography from '@mui/material/Typography' +import { useTranslation } from 'next-i18next' +import { + Component, + ErrorInfo, + ReactElement, + ReactNode +} from 'react' + +interface UserDeleteErrorBoundaryProps { + children: ReactNode +} + +interface UserDeleteErrorBoundaryState { + hasError: boolean + error: Error | null +} + +export class UserDeleteErrorBoundary extends Component< + UserDeleteErrorBoundaryProps, + UserDeleteErrorBoundaryState +> { + constructor(props: UserDeleteErrorBoundaryProps) { + super(props) + this.state = { hasError: false, error: null } + } + + static getDerivedStateFromError(error: Error): UserDeleteErrorBoundaryState { + return { hasError: true, error } + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('UserDelete error:', error, errorInfo) + } + + render(): ReactNode { + if (this.state.hasError) { + return + } + return this.props.children + } +} + +function UserDeleteErrorFallback({ + error +}: { + error: Error | null +}): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + return ( + + + {t('Something went wrong')} + + {error?.message ?? t('An unexpected error occurred.')} + + + + ) +} diff --git a/apps/journeys-admin/src/libs/apolloClient/apolloClient.test.ts b/apps/journeys-admin/src/libs/apolloClient/apolloClient.test.ts index 6df2745baa1..3dce1474333 100644 --- a/apps/journeys-admin/src/libs/apolloClient/apolloClient.test.ts +++ b/apps/journeys-admin/src/libs/apolloClient/apolloClient.test.ts @@ -1,6 +1,33 @@ -import { createApolloClient } from './apolloClient' +import { + ApolloLink, + FetchResult, + Observable, + execute, + from, + gql +} from '@apollo/client' +import { print } from 'graphql' +import { createClient } from 'graphql-sse' + +import { logout } from '../auth/firebase' +import { SSELink, createApolloClient, createErrorLink } from './apolloClient' + +jest.mock('graphql-sse', () => ({ + createClient: jest.fn() +})) + +jest.mock('../auth/firebase', () => ({ + logout: jest.fn().mockResolvedValue(undefined) +})) + +const mockCreateClient = createClient as jest.MockedFunction +const mockLogout = logout as jest.MockedFunction + +describe('createApolloClient', () => { + beforeEach(() => { + mockCreateClient.mockReturnValue({ subscribe: jest.fn() } as unknown as ReturnType) + }) -describe('Apollo SSE Client', () => { it('should create Apollo client with SSE support', () => { const client = createApolloClient() expect(client).toBeDefined() @@ -15,3 +42,198 @@ describe('Apollo SSE Client', () => { expect(client.cache).toBeDefined() }) }) + +describe('SSELink', () => { + let mockSubscribe: jest.Mock + + beforeEach(() => { + mockSubscribe = jest.fn() + mockCreateClient.mockReturnValue({ + subscribe: mockSubscribe + } as unknown as ReturnType) + }) + + it('forwards operation query, variables, and operationName to graphql-sse client', (done) => { + mockSubscribe.mockImplementation((_op, sink) => { + sink.next({ data: { item: { id: 'item-1' } } }) + sink.complete() + return (): void => {} + }) + + const link = new SSELink('http://localhost:4000') + const doc = gql` + query TestOp($id: ID!) { + item(id: $id) { + id + } + } + ` + const vars = { id: 'item-1' } + + link + .request( + { + query: doc, + variables: vars, + operationName: 'TestOp', + getContext: () => ({ headers: {} }), + setContext: jest.fn(), + extensions: {} + }, + undefined + ) + ?.subscribe({ + next: () => { + expect(mockSubscribe).toHaveBeenCalledWith( + expect.objectContaining({ + query: print(doc), + variables: vars, + operationName: 'TestOp' + }), + expect.any(Object) + ) + done() + }, + error: done + }) + }) + + it('forwards auth headers from operation context', (done) => { + mockSubscribe.mockImplementation((_op, sink) => { + sink.next({ data: {} }) + sink.complete() + return (): void => {} + }) + + const link = new SSELink('http://localhost:4000') + const doc = gql` + subscription TestSub { + ping + } + ` + + link + .request( + { + query: doc, + variables: {}, + operationName: 'TestSub', + getContext: () => ({ headers: { Authorization: 'JWT my-token' } }), + setContext: jest.fn(), + extensions: {} + }, + undefined + ) + ?.subscribe({ + next: () => { + // The headers factory in createClient is called at subscribe time, + // so we verify that the client was created and subscribe was called + expect(mockSubscribe).toHaveBeenCalled() + done() + }, + error: done + }) + }) + + it('propagates errors from graphql-sse sink to the observer', (done) => { + const sseError = new Error('SSE connection failed') + mockSubscribe.mockImplementation((_op, sink) => { + sink.error(sseError) + return (): void => {} + }) + + const link = new SSELink('http://localhost:4000') + const doc = gql` + subscription TestSub { + ping + } + ` + + link + .request( + { + query: doc, + variables: {}, + operationName: 'TestSub', + getContext: () => ({}), + setContext: jest.fn(), + extensions: {} + }, + undefined + ) + ?.subscribe({ + error: (err: Error) => { + expect(err).toBe(sseError) + done() + } + }) + }) +}) + +describe('createErrorLink', () => { + beforeEach(() => { + mockLogout.mockClear() + }) + + it('calls logout and propagates Session expired error on UNAUTHENTICATED', (done) => { + const errorLink = createErrorLink() + + const terminatingLink = new ApolloLink( + () => + new Observable((observer) => { + observer.next({ + errors: [ + { + message: 'Not authenticated', + extensions: { code: 'UNAUTHENTICATED' }, + locations: undefined, + path: undefined + } + ] + }) + observer.complete() + }) + ) + + const link = from([errorLink, terminatingLink]) + + execute(link, { query: gql`query Test { test }` }).subscribe({ + error: (err: Error) => { + expect(mockLogout).toHaveBeenCalled() + expect(err.message).toBe('Session expired') + done() + } + }) + }) + + it('does not call logout for non-UNAUTHENTICATED errors', (done) => { + const errorLink = createErrorLink() + + const terminatingLink = new ApolloLink( + () => + new Observable((observer) => { + observer.next({ + errors: [ + { + message: 'Something went wrong', + extensions: { code: 'INTERNAL_SERVER_ERROR' }, + locations: undefined, + path: undefined + } + ] + }) + observer.complete() + }) + ) + + const link = from([errorLink, terminatingLink]) + + execute(link, { query: gql`query Test { test }` }).subscribe({ + next: () => { + expect(mockLogout).not.toHaveBeenCalled() + }, + complete: done, + error: done + }) + }) +}) diff --git a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts index 4b9b8189cc5..1cea3f4e38e 100644 --- a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts +++ b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts @@ -17,7 +17,8 @@ import { getMainDefinition } from '@apollo/client/utilities' import DebounceLink from 'apollo-link-debounce' import { getApp } from 'firebase/app' import { getAuth } from 'firebase/auth' -import { createClient } from 'graphql-sse' +import { Client, createClient } from 'graphql-sse' +import { print } from 'graphql' import { useMemo } from 'react' import { Observable } from 'zen-observable-ts' @@ -30,40 +31,38 @@ let apolloClient: ApolloClient const DEFAULT_DEBOUNCE_TIMEOUT = 500 -// Custom Apollo Link for Server-Sent Events using graphql-sse -class SSELink extends ApolloLink { +// Custom Apollo Link for Server-Sent Events using graphql-sse. +// The graphql-sse Client is created once (singleton) and reused across +// subscriptions. The auth headers are captured at request time via the +// `headers` factory function so each SSE connection uses the current token. +export class SSELink extends ApolloLink { private url: string - private options: any + private client: Client + private headers: Record = {} - constructor(url: string, options?: any) { + constructor(url: string) { super() this.url = url - this.options = options || {} + this.client = createClient({ + url, + headers: () => ({ + ...this.headers, + 'Content-Type': 'application/json', + Accept: 'text/event-stream' + }) + }) } public request( operation: Operation, - forward?: NextLink + _forward?: NextLink ): Observable | null { - return new Observable((observer) => { - // Get headers from operation context - const context = operation.getContext() - const headers = context.headers || {} - - // Create a new client instance with current headers - const client = createClient({ - url: this.url, - ...this.options, - headers: { - ...headers, - 'Content-Type': 'application/json', - Accept: 'text/event-stream' - } - }) + this.headers = operation.getContext().headers ?? {} - const unsubscribe = client.subscribe( + return new Observable((observer) => { + const unsubscribe = this.client.subscribe( { - query: operation.query.loc?.source?.body || '', + query: print(operation.query), variables: operation.variables, operationName: operation.operationName }, @@ -90,6 +89,25 @@ class SSELink extends ApolloLink { } } +// Creates the error link that handles UNAUTHENTICATED errors by logging the +// user out and propagating a settled error observable. +export function createErrorLink(): ApolloLink { + return onError(({ graphQLErrors }) => { + if ( + !ssrMode && + graphQLErrors?.some((e) => e.extensions?.code === 'UNAUTHENTICATED') === + true + ) { + void logout() + // Propagate a settled error so any awaiting promise rejects cleanly + // while logout() clears the Firebase token and redirects to sign-in + return new Observable((observer) => { + observer.error(new Error('Session expired')) + }) + } + }) +} + export function createApolloClient( token?: string ): ApolloClient { @@ -121,20 +139,7 @@ export function createApolloClient( } }) - const errorLink = onError(({ graphQLErrors }) => { - if ( - !ssrMode && - graphQLErrors?.some((e) => e.extensions?.code === 'UNAUTHENTICATED') === - true - ) { - void logout() - // Propagate a settled error so any awaiting promise rejects cleanly - // while logout() clears the Firebase token and redirects to sign-in - return new Observable((observer) => { - observer.error(new Error('Session expired')) - }) - } - }) + const errorLink = createErrorLink() const mutationQueueLink = new MutationQueueLink() const debounceLink = new DebounceLink(DEFAULT_DEBOUNCE_TIMEOUT) diff --git a/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts b/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts index 43d43064673..cf9eefcfa43 100644 --- a/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts +++ b/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts @@ -36,24 +36,33 @@ export async function checkConditionalRedirect({ teamName, allowGuest = false }: CheckConditionalRedirectProps): Promise { - const currentRedirect = new URL( + const requestedRedirect = new URL( resolvedUrl, 'https://admin.nextstep.is' ).searchParams.get('redirect') - let redirect = '' + const currentRedirect = + requestedRedirect != null && + requestedRedirect.startsWith('/') && + !requestedRedirect.startsWith('//') + ? requestedRedirect + : null + + let redirectForApi: string | undefined + let encodedRedirect = '' if (currentRedirect != null) { - redirect = `?redirect=${encodeURIComponent(currentRedirect)}` - } else { - if (resolvedUrl !== '/') - redirect = `?redirect=${encodeURIComponent(resolvedUrl)}` + redirectForApi = currentRedirect + encodedRedirect = `?redirect=${encodeURIComponent(currentRedirect)}` + } else if (resolvedUrl !== '/') { + redirectForApi = resolvedUrl + encodedRedirect = `?redirect=${encodeURIComponent(resolvedUrl)}` } let meResult: GetMe | undefined try { const { data } = await apolloClient.query({ query: GET_ME, - variables: { input: { redirect } } + variables: { input: { redirect: redirectForApi } } }) meResult = data } catch (error) { @@ -78,7 +87,7 @@ export async function checkConditionalRedirect({ if (!(me.me?.emailVerified ?? false)) { if (resolvedUrl.startsWith('/users/verify')) return return { - destination: `/users/verify${redirect}`, + destination: `/users/verify${encodedRedirect}`, permanent: false } } @@ -88,8 +97,8 @@ export async function checkConditionalRedirect({ return } - // don't redirect on /users/verify - if (resolvedUrl.startsWith(`/users/verify${redirect}`)) return + // don't redirect when already on /users/verify with the same redirect param + if (resolvedUrl.startsWith(`/users/verify${encodedRedirect}`)) return const { data } = await apolloClient.query({ query: GET_JOURNEY_PROFILE_AND_TEAMS @@ -98,7 +107,24 @@ export async function checkConditionalRedirect({ if (data.getJourneyProfile?.acceptedTermsAt == null) { if (resolvedUrl.startsWith('/users/terms-and-conditions')) return return { - destination: `/users/terms-and-conditions${redirect}`, + destination: `/users/terms-and-conditions${encodedRedirect}`, + permanent: false + } + } + + // Terms already accepted — skip past the terms page if we're still on it + // (e.g. redirected here after email verification via link). + // Only forward the original ?redirect= destination, not the terms URL itself. + if (resolvedUrl.startsWith('/users/terms-and-conditions')) { + const forwardRedirect = currentRedirect != null ? encodedRedirect : '' + if (data.teams.length === 0) { + return { + destination: `/teams/new${forwardRedirect}`, + permanent: false + } + } + return { + destination: currentRedirect ?? '/', permanent: false } } @@ -113,7 +139,7 @@ export async function checkConditionalRedirect({ } if (resolvedUrl.startsWith('/teams/new')) return return { - destination: `/teams/new${redirect}`, + destination: `/teams/new${encodedRedirect}`, permanent: false } } From 3be2e2f9628f4db6bc8aaaf575db44081e3c790c Mon Sep 17 00:00:00 2001 From: Siyang Date: Wed, 15 Apr 2026 19:35:58 +1200 Subject: [PATCH 22/29] chore: restore backend files to match main (rebase cleanup) Remove backend-only changes that slipped through during rebase conflict resolution (isFirebaseNotFound additions to user.ts and types.ts). Co-Authored-By: Claude Sonnet 4.6 --- apis/api-users/src/schema/user/user.ts | 9 --------- apis/api-users/src/schema/userDelete/service/types.ts | 9 --------- 2 files changed, 18 deletions(-) diff --git a/apis/api-users/src/schema/user/user.ts b/apis/api-users/src/schema/user/user.ts index 2841796d338..979e9668fd7 100644 --- a/apis/api-users/src/schema/user/user.ts +++ b/apis/api-users/src/schema/user/user.ts @@ -3,15 +3,6 @@ import { GraphQLError } from 'graphql' import { Prisma, prisma } from '@core/prisma/users/client' import { impersonateUser } from '@core/yoga/firebaseClient' -function isFirebaseNotFound(error: unknown): boolean { - return ( - error != null && - typeof error === 'object' && - 'code' in error && - (error as { code: string }).code === 'auth/user-not-found' - ) -} - import { builder } from '../builder' import { findOrFetchUser } from './findOrFetchUser' diff --git a/apis/api-users/src/schema/userDelete/service/types.ts b/apis/api-users/src/schema/userDelete/service/types.ts index a2ed4eb92e3..fea3749fbf5 100644 --- a/apis/api-users/src/schema/userDelete/service/types.ts +++ b/apis/api-users/src/schema/userDelete/service/types.ts @@ -9,12 +9,3 @@ export function isFirebaseNotFound(error: unknown): boolean { error.code === 'auth/user-not-found' ) } - -export function isFirebaseNotFound(error: unknown): boolean { - return ( - error != null && - typeof error === 'object' && - 'code' in error && - error.code === 'auth/user-not-found' - ) -} From 812fe5bb2f143852ed19412a62851ea62c412902 Mon Sep 17 00:00:00 2001 From: Siyang Date: Wed, 15 Apr 2026 19:50:53 +1200 Subject: [PATCH 23/29] fix: resolve lint errors in UserDelete and apolloClient files - Fix import/order in delete.tsx, UserDelete.tsx, apolloClient.ts - Fix no-void: convert expression arrow bodies to block bodies - Fix @typescript-eslint/no-empty-function: replace empty unsubscribe callbacks with () => undefined in apolloClient.test.ts - Fix missing blank line between import groups in apolloClient.test.ts Co-Authored-By: Claude Sonnet 4.6 --- apps/journeys-admin/pages/users/delete.tsx | 2 +- .../src/components/UserDelete/UserDelete.tsx | 8 ++++---- .../src/libs/apolloClient/apolloClient.test.ts | 7 ++++--- apps/journeys-admin/src/libs/apolloClient/apolloClient.ts | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/journeys-admin/pages/users/delete.tsx b/apps/journeys-admin/pages/users/delete.tsx index 255bd9da02b..18fac0907e1 100644 --- a/apps/journeys-admin/pages/users/delete.tsx +++ b/apps/journeys-admin/pages/users/delete.tsx @@ -5,8 +5,8 @@ import { ReactElement } from 'react' import { GetMe, GetMeVariables } from '../../__generated__/GetMe' import { PageWrapper } from '../../src/components/PageWrapper' -import { UserDelete } from '../../src/components/UserDelete' import { GET_ME } from '../../src/components/PageWrapper/NavigationDrawer/UserNavigation/UserNavigation' +import { UserDelete } from '../../src/components/UserDelete' import { useAuth } from '../../src/libs/auth' import { getAuthTokens, diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx index ba39cf7b0b0..c6573a1019f 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx @@ -4,11 +4,11 @@ import { useSubscription, useSuspenseQuery } from '@apollo/client' -import CircularProgress from '@mui/material/CircularProgress' import Alert from '@mui/material/Alert' import AlertTitle from '@mui/material/AlertTitle' import Box from '@mui/material/Box' import Button from '@mui/material/Button' +import CircularProgress from '@mui/material/CircularProgress' import FormControl from '@mui/material/FormControl' import InputLabel from '@mui/material/InputLabel' import MenuItem from '@mui/material/MenuItem' @@ -54,8 +54,8 @@ import { USER_DELETE_JOURNEYS_CHECK, USER_DELETE_JOURNEYS_CONFIRM } from './UserDelete.documents' -import { UserDeleteErrorBoundary } from './UserDeleteErrorBoundary' import { UserDeleteConfirmDialog } from './UserDeleteConfirmDialog' +import { UserDeleteErrorBoundary } from './UserDeleteErrorBoundary' export { USER_DELETE_CHECK, @@ -423,7 +423,7 @@ function UserDeleteContent(): ReactElement { - + diff --git a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts index dcd68851df6..1362afd0e21 100644 --- a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts +++ b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts @@ -18,7 +18,7 @@ import DebounceLink from 'apollo-link-debounce' import { getApp } from 'firebase/app' import { getAuth } from 'firebase/auth' import { print } from 'graphql' -import { Client, createClient } from 'graphql-sse' +import { createClient } from 'graphql-sse' import { useMemo } from 'react' import { Observable } from 'zen-observable-ts' @@ -32,35 +32,33 @@ let apolloClient: ApolloClient const DEFAULT_DEBOUNCE_TIMEOUT = 500 // Custom Apollo Link for Server-Sent Events using graphql-sse. -// The graphql-sse Client is created once (singleton) and reused across -// subscriptions. The auth headers are captured at request time via the -// `headers` factory function so each SSE connection uses the current token. +// A new graphql-sse Client is created per request so each subscription +// captures its own auth headers in a closure, avoiding race conditions +// between concurrent subscriptions sharing mutable state. export class SSELink extends ApolloLink { private url: string - private client: Client - private headers: Record = {} constructor(url: string) { super() this.url = url - this.client = createClient({ - url, - headers: () => ({ - ...this.headers, - 'Content-Type': 'application/json', - Accept: 'text/event-stream' - }) - }) } public request( operation: Operation, _forward?: NextLink ): Observable | null { - this.headers = operation.getContext().headers ?? {} + const headers = operation.getContext().headers ?? {} + const client = createClient({ + url: this.url, + headers: () => ({ + ...headers, + 'Content-Type': 'application/json', + Accept: 'text/event-stream' + }) + }) return new Observable((observer) => { - const unsubscribe = this.client.subscribe( + const unsubscribe = client.subscribe( { query: print(operation.query), variables: operation.variables, From 925cebb1fc10395d4313305db5e10964161469b0 Mon Sep 17 00:00:00 2001 From: Siyang Date: Wed, 15 Apr 2026 20:11:02 +1200 Subject: [PATCH 26/29] fix: restore JourneyCustomizationDescriptionTranslateInput to globalTypes During rebase conflict resolution the auto-merge for the 'fix: lint issues' commit (fb11c3132) accidentally removed the JourneyCustomizationDescription- TranslateInput interface from the generated globalTypes.ts in apps/journeys, apps/resources, and apps/watch. This broke type-check for those three projects because apps/journeys/__generated__/JourneyCustomizationDescriptionTranslate.ts imports that type. Restore all three files to match origin/main exactly. Co-Authored-By: Claude Sonnet 4.6 --- apps/journeys/__generated__/globalTypes.ts | 6 ++++++ apps/resources/__generated__/globalTypes.ts | 6 ++++++ apps/watch/__generated__/globalTypes.ts | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/apps/journeys/__generated__/globalTypes.ts b/apps/journeys/__generated__/globalTypes.ts index 7c7621e6f75..7682ab3400a 100644 --- a/apps/journeys/__generated__/globalTypes.ts +++ b/apps/journeys/__generated__/globalTypes.ts @@ -297,6 +297,12 @@ export interface DateTimeFilter { lte?: any | null; } +export interface JourneyCustomizationDescriptionTranslateInput { + journeyId: string; + sourceLanguageName: string; + targetLanguageName: string; +} + export interface JourneyProfileUpdateInput { lastActiveTeamId?: string | null; journeyFlowBackButtonClicked?: boolean | null; diff --git a/apps/resources/__generated__/globalTypes.ts b/apps/resources/__generated__/globalTypes.ts index 57cc571cd1e..1fde51e51fa 100644 --- a/apps/resources/__generated__/globalTypes.ts +++ b/apps/resources/__generated__/globalTypes.ts @@ -303,6 +303,12 @@ export interface DateTimeFilter { lte?: any | null; } +export interface JourneyCustomizationDescriptionTranslateInput { + journeyId: string; + sourceLanguageName: string; + targetLanguageName: string; +} + export interface JourneyProfileUpdateInput { lastActiveTeamId?: string | null; journeyFlowBackButtonClicked?: boolean | null; diff --git a/apps/watch/__generated__/globalTypes.ts b/apps/watch/__generated__/globalTypes.ts index 57cc571cd1e..1fde51e51fa 100644 --- a/apps/watch/__generated__/globalTypes.ts +++ b/apps/watch/__generated__/globalTypes.ts @@ -303,6 +303,12 @@ export interface DateTimeFilter { lte?: any | null; } +export interface JourneyCustomizationDescriptionTranslateInput { + journeyId: string; + sourceLanguageName: string; + targetLanguageName: string; +} + export interface JourneyProfileUpdateInput { lastActiveTeamId?: string | null; journeyFlowBackButtonClicked?: boolean | null; From ea89d14ff7a0c72e3475d5e4f6eee7c22eec7660 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:14:48 +0000 Subject: [PATCH 27/29] fix: lint issues --- .../components/UserDelete/UserDelete.spec.tsx | 13 ++++++---- .../src/components/UserDelete/UserDelete.tsx | 8 +++++-- .../UserDelete/UserDeleteErrorBoundary.tsx | 7 +----- .../libs/apolloClient/apolloClient.test.ts | 24 +++++++++++++++---- libs/locales/en/apps-journeys-admin.json | 2 +- 5 files changed, 37 insertions(+), 17 deletions(-) diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx index 20510e18867..9405862dfab 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.spec.tsx @@ -21,7 +21,9 @@ jest.mock('notistack', () => ({ useSnackbar: () => ({ enqueueSnackbar: mockEnqueueSnackbar }), - SnackbarProvider: ({ children }: { children: React.ReactNode }) => <>{children} + SnackbarProvider: ({ children }: { children: React.ReactNode }) => ( + <>{children} + ) })) const mockUseSuspenseQuery = jest.fn() @@ -357,9 +359,12 @@ describe('UserDeleteWithErrorBoundary', () => { }) await waitFor(() => { - expect(mockEnqueueSnackbar).toHaveBeenCalledWith('User deleted successfully', { - variant: 'success' - }) + expect(mockEnqueueSnackbar).toHaveBeenCalledWith( + 'User deleted successfully', + { + variant: 'success' + } + ) }) }) diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx index c6573a1019f..1cc0f727fa8 100644 --- a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx @@ -423,7 +423,9 @@ function UserDeleteContent(): ReactElement {