diff --git a/apps/journeys-admin/__generated__/UserDeleteCheck.ts b/apps/journeys-admin/__generated__/UserDeleteCheck.ts new file mode 100644 index 00000000000..ecc0247fde5 --- /dev/null +++ b/apps/journeys-admin/__generated__/UserDeleteCheck.ts @@ -0,0 +1,40 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { UserDeleteIdType } from "./globalTypes"; + +// ==================================================== +// GraphQL mutation operation: UserDeleteCheck +// ==================================================== + +export interface UserDeleteCheck_userDeleteCheck_logs { + __typename: "UserDeleteLogEntry"; + message: string; + level: string; + timestamp: string; +} + +export interface UserDeleteCheck_userDeleteCheck { + __typename: "UserDeleteCheckResult"; + userId: string; + userEmail: string | null; + userFirstName: string; + journeysToDelete: number; + journeysToTransfer: number; + journeysToRemove: number; + teamsToDelete: number; + teamsToTransfer: number; + teamsToRemove: number; + logs: UserDeleteCheck_userDeleteCheck_logs[]; +} + +export interface UserDeleteCheck { + userDeleteCheck: UserDeleteCheck_userDeleteCheck; +} + +export interface UserDeleteCheckVariables { + idType: UserDeleteIdType; + id: string; +} diff --git a/apps/journeys-admin/__generated__/UserDeleteConfirmSubscription.ts b/apps/journeys-admin/__generated__/UserDeleteConfirmSubscription.ts new file mode 100644 index 00000000000..47dff478978 --- /dev/null +++ b/apps/journeys-admin/__generated__/UserDeleteConfirmSubscription.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +import { UserDeleteIdType } from "./globalTypes"; + +// ==================================================== +// GraphQL subscription operation: UserDeleteConfirmSubscription +// ==================================================== + +export interface UserDeleteConfirmSubscription_userDeleteConfirm_log { + __typename: "UserDeleteLogEntry"; + message: string; + level: string; + timestamp: string; +} + +export interface UserDeleteConfirmSubscription_userDeleteConfirm { + __typename: "UserDeleteConfirmProgress"; + log: UserDeleteConfirmSubscription_userDeleteConfirm_log; + done: boolean; + success: boolean | null; +} + +export interface UserDeleteConfirmSubscription { + userDeleteConfirm: UserDeleteConfirmSubscription_userDeleteConfirm; +} + +export interface UserDeleteConfirmSubscriptionVariables { + idType: UserDeleteIdType; + id: string; +} diff --git a/apps/journeys-admin/__generated__/globalTypes.ts b/apps/journeys-admin/__generated__/globalTypes.ts index e3474fe6b6c..6e771aeb0df 100644 --- a/apps/journeys-admin/__generated__/globalTypes.ts +++ b/apps/journeys-admin/__generated__/globalTypes.ts @@ -328,6 +328,11 @@ export enum TypographyVariant { subtitle2 = "subtitle2", } +export enum UserDeleteIdType { + databaseId = "databaseId", + email = "email", +} + export enum UserJourneyRole { editor = "editor", inviteRequested = "inviteRequested", diff --git a/apps/journeys-admin/pages/api/clear-auth.ts b/apps/journeys-admin/pages/api/clear-auth.ts new file mode 100644 index 00000000000..e1bb8467b0f --- /dev/null +++ b/apps/journeys-admin/pages/api/clear-auth.ts @@ -0,0 +1,14 @@ +import { NextApiRequest, NextApiResponse } from 'next' + +import { authConfig } from '../../src/libs/auth/config' + +export default function handler( + _req: NextApiRequest, + res: NextApiResponse +): void { + 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/pages/users/delete.tsx b/apps/journeys-admin/pages/users/delete.tsx new file mode 100644 index 00000000000..8bbd2180d70 --- /dev/null +++ b/apps/journeys-admin/pages/users/delete.tsx @@ -0,0 +1,49 @@ +import { GetServerSidePropsContext } from 'next' +import { useTranslation } from 'next-i18next' +import { NextSeo } from 'next-seo' +import { ReactElement } from 'react' + +import { PageWrapper } from '../../src/components/PageWrapper' +import { UserDelete } from '../../src/components/UserDelete' +import { useAuth } from '../../src/libs/auth' +import { + getAuthTokens, + redirectToLogin, + toUser +} from '../../src/libs/auth/getAuthTokens' +import { initAndAuthApp } from '../../src/libs/initAndAuthApp' + +export default function UserDeletePage(): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const { user } = useAuth() + + return ( + <> + + + + + + ) +} + +export const getServerSideProps = async (ctx: GetServerSidePropsContext) => { + const tokens = await getAuthTokens(ctx) + if (tokens == null) return redirectToLogin(ctx) + const user = toUser(tokens) + + const { redirect, translations } = await initAndAuthApp({ + user, + locale: ctx.locale, + resolvedUrl: ctx.resolvedUrl + }) + + if (redirect != null) return { redirect } + + return { + props: { + userSerialized: JSON.stringify(user), + ...translations + } + } +} diff --git a/apps/journeys-admin/src/components/PageWrapper/NavigationDrawer/UserNavigation/UserNavigation.tsx b/apps/journeys-admin/src/components/PageWrapper/NavigationDrawer/UserNavigation/UserNavigation.tsx index e9cbc4cada3..79e46f6e221 100644 --- a/apps/journeys-admin/src/components/PageWrapper/NavigationDrawer/UserNavigation/UserNavigation.tsx +++ b/apps/journeys-admin/src/components/PageWrapper/NavigationDrawer/UserNavigation/UserNavigation.tsx @@ -12,6 +12,7 @@ import { useTranslation } from 'next-i18next' import { MouseEvent, ReactElement, useEffect, useState } from 'react' import BoxIcon from '@core/shared/ui/icons/Box' +import Trash2Icon from '@core/shared/ui/icons/Trash2' import UserProfile3Icon from '@core/shared/ui/icons/UserProfile3' import { GetMe } from '../../../../../__generated__/GetMe' @@ -147,6 +148,23 @@ export function UserNavigation({ /> )} + {data.me?.__typename === 'AuthenticatedUser' && + data.me.superAdmin === true && ( + + + + + + + )} {data.me?.__typename === 'AuthenticatedUser' && ( ({ + useRouter: () => ({ + push: mockPush, + query: {} + }) +})) + +jest.mock('notistack', () => ({ + useSnackbar: () => ({ + enqueueSnackbar: jest.fn() + }) +})) + +const mockUseSuspenseQuery = jest.fn() +jest.mock('@apollo/client', () => { + const actual = jest.requireActual('@apollo/client') + return { + ...actual, + useSuspenseQuery: (...args: unknown[]) => mockUseSuspenseQuery(...args) + } +}) + +describe('UserDeleteWithErrorBoundary', () => { + beforeEach(() => { + jest.clearAllMocks() + mockUseSuspenseQuery.mockReturnValue({ + data: { + me: { + __typename: 'AuthenticatedUser', + id: 'user-1', + superAdmin: true + } + } + }) + }) + + it('should render the form for superAdmin users', () => { + const { getAllByText, getByText } = render( + + + + ) + + expect(getAllByText('Delete User').length).toBeGreaterThanOrEqual(1) + expect(getByText('Check')).toBeInTheDocument() + expect(getByText('Warning')).toBeInTheDocument() + }) + + it('should redirect non-superAdmin users', () => { + mockUseSuspenseQuery.mockReturnValue({ + data: { + me: { + __typename: 'AuthenticatedUser', + id: 'user-1', + superAdmin: false + } + } + }) + + render( + + + + ) + + expect(mockPush).toHaveBeenCalledWith('/') + }) + + it('should render empty for non-superAdmin', () => { + mockUseSuspenseQuery.mockReturnValue({ + data: { + me: { + __typename: 'AuthenticatedUser', + id: 'user-1', + superAdmin: false + } + } + }) + + const { queryByText } = render( + + + + ) + + expect(queryByText('Check')).not.toBeInTheDocument() + }) + + it('should have delete button disabled before check', () => { + const { getAllByRole } = render( + + + + ) + + const deleteUserButtons = getAllByRole('button', { name: 'Delete User' }) + const actionBtn = deleteUserButtons[deleteUserButtons.length - 1] + expect(actionBtn).toBeDisabled() + }) + + it('should have check button disabled when input is empty', () => { + const { getByText } = render( + + + + ) + + expect(getByText('Check').closest('button')).toBeDisabled() + }) + + it('should render lookup type selector with email as default', () => { + const { getByLabelText } = render( + + + + ) + + expect(getByLabelText('Lookup By')).toBeInTheDocument() + }) + + it('should render logs textfield', () => { + const { getByRole } = render( + + + + ) + + const logsField = getByRole('textbox', { name: 'Logs' }) + expect(logsField).toBeInTheDocument() + expect(logsField).toHaveAttribute('readonly') + }) +}) diff --git a/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx new file mode 100644 index 00000000000..3b3dd512714 --- /dev/null +++ b/apps/journeys-admin/src/components/UserDelete/UserDelete.tsx @@ -0,0 +1,428 @@ +import { + ApolloError, + gql, + useMutation, + useSubscription, + useSuspenseQuery +} from '@apollo/client' +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 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 FormControl from '@mui/material/FormControl' +import InputLabel from '@mui/material/InputLabel' +import MenuItem from '@mui/material/MenuItem' +import Select from '@mui/material/Select' +import Stack from '@mui/material/Stack' +import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +import { useSnackbar } from 'notistack' +import { + Component, + ErrorInfo, + ReactElement, + ReactNode, + Suspense, + useCallback, + useEffect, + useRef, + useState +} from 'react' + +import { GetMe } from '../../../__generated__/GetMe' +import { UserDeleteIdType } from '../../../__generated__/globalTypes' +import { + UserDeleteCheck, + UserDeleteCheckVariables +} from '../../../__generated__/UserDeleteCheck' +import { + UserDeleteConfirmSubscription, + UserDeleteConfirmSubscriptionVariables +} from '../../../__generated__/UserDeleteConfirmSubscription' +import { GET_ME } from '../PageWrapper/NavigationDrawer/UserNavigation/UserNavigation' + +interface LogEntry { + message: string + level: string + timestamp: string +} + +export const USER_DELETE_CHECK = gql` + mutation UserDeleteCheck($idType: UserDeleteIdType!, $id: String!) { + userDeleteCheck(idType: $idType, id: $id) { + userId + userEmail + userFirstName + journeysToDelete + journeysToTransfer + journeysToRemove + teamsToDelete + teamsToTransfer + teamsToRemove + logs { + message + level + timestamp + } + } + } +` + +export const USER_DELETE_CONFIRM = gql` + subscription UserDeleteConfirmSubscription( + $idType: UserDeleteIdType! + $id: String! + ) { + userDeleteConfirm(idType: $idType, id: $id) { + log { + message + level + timestamp + } + done + success + } + } +` + +interface UserDeleteErrorBoundaryProps { + children: ReactNode +} + +interface UserDeleteErrorBoundaryState { + hasError: boolean + error: Error | null +} + +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.')} + + + + ) +} + +export function UserDeleteWithErrorBoundary(): ReactElement { + return ( + + + + + + ) +} + +interface ConfirmVars { + idType: UserDeleteIdType + id: string +} + +const levelLabel: Record = { + error: 'ERROR', + warn: 'WARN', + info: 'INFO' +} + +function UserDeleteContent(): ReactElement { + const { t } = useTranslation('apps-journeys-admin') + const router = useRouter() + const { enqueueSnackbar } = useSnackbar() + const { data } = useSuspenseQuery(GET_ME, { + variables: { input: { redirect: router?.query?.redirect } } + }) + + const [idType, setIdType] = useState(UserDeleteIdType.email) + const [userId, setUserId] = useState('') + const [logs, setLogs] = useState([]) + const [checkComplete, setCheckComplete] = useState(false) + const [confirmOpen, setConfirmOpen] = useState(false) + const [confirmVars, setConfirmVars] = useState(null) + + const [userDeleteCheck, { loading: checkLoading }] = useMutation< + UserDeleteCheck, + UserDeleteCheckVariables + >(USER_DELETE_CHECK) + + useSubscription< + UserDeleteConfirmSubscription, + UserDeleteConfirmSubscriptionVariables + >(USER_DELETE_CONFIRM, { + skip: confirmVars == null, + variables: confirmVars ?? { idType: UserDeleteIdType.email, id: '' }, + onData: ({ data: subData }) => { + const progress = subData.data?.userDeleteConfirm + if (progress == null) return + + setLogs((prev) => [...prev, progress.log]) + + if (progress.done) { + 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' + }) + } + } + }, + onError: (error) => { + 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 }) + setConfirmVars(null) + } + }) + + const confirmLoading = confirmVars != null + + const isSuperAdmin = + data.me?.__typename === 'AuthenticatedUser' && data.me.superAdmin === true + + useEffect(() => { + if (!isSuperAdmin) { + void router.push('/') + } + }, [isSuperAdmin, router]) + + const logBoxRef = useRef(null) + + useEffect(() => { + const el = logBoxRef.current + if (el != null) { + el.scrollTop = el.scrollHeight + } + }, [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 handleCheck = useCallback(async () => { + if (userId.trim() === '') return + + setLogs([]) + setCheckComplete(false) + + try { + const { data: checkData } = await userDeleteCheck({ + variables: { idType, id: userId.trim() } + }) + + if (checkData?.userDeleteCheck != null) { + setLogs(checkData.userDeleteCheck.logs) + setCheckComplete(true) + } + } 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, enqueueSnackbar]) + + const handleConfirmDelete = useCallback(() => { + setConfirmOpen(false) + setConfirmVars({ idType, id: userId.trim() }) + }, [idType, userId]) + + if (!isSuperAdmin) return <> + + return ( + + + {t('Delete User')} + + + + {t('Warning')} + + {t( + 'This action permanently deletes a user and their associated data. This cannot be undone. Always run a check first to review what will be deleted.' + )} + + + + + + {t('Lookup By')} + + + + { + setUserId(e.target.value) + setCheckComplete(false) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') void handleCheck() + }} + disabled={checkLoading || confirmLoading} + fullWidth + /> + + + + + + + + + + + setConfirmOpen(false)} + aria-labelledby="confirm-delete-title" + > + + {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/index.ts b/apps/journeys-admin/src/components/UserDelete/index.ts new file mode 100644 index 00000000000..6f5d664123e --- /dev/null +++ b/apps/journeys-admin/src/components/UserDelete/index.ts @@ -0,0 +1 @@ +export { UserDeleteWithErrorBoundary as UserDelete } from './UserDelete' diff --git a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts index 2b6fcb640fc..279254eb31e 100644 --- a/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts +++ b/apps/journeys-admin/src/libs/apolloClient/apolloClient.ts @@ -12,6 +12,7 @@ import { } from '@apollo/client' import { EntityStore, StoreObject } from '@apollo/client/cache' import { setContext } from '@apollo/client/link/context' +import { onError } from '@apollo/client/link/error' import { getMainDefinition } from '@apollo/client/utilities' import DebounceLink from 'apollo-link-debounce' import { getApp } from 'firebase/app' @@ -20,6 +21,8 @@ import { createClient } from 'graphql-sse' import { useMemo } from 'react' import { Observable } from 'zen-observable-ts' +import { logout } from '../auth/firebase' + import { cache } from './cache' const ssrMode = typeof window === 'undefined' @@ -118,6 +121,21 @@ export function createApolloClient( } }) + const errorLink = onError(({ graphQLErrors }) => { + if ( + !ssrMode && + graphQLErrors?.some((e) => e.extensions?.code === 'UNAUTHENTICATED') === + true + ) { + void logout() + // 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.complete() + }) + } + }) + const mutationQueueLink = new MutationQueueLink() const debounceLink = new DebounceLink(DEFAULT_DEBOUNCE_TIMEOUT) @@ -134,7 +152,13 @@ export function createApolloClient( httpLink ) - const link = from([debounceLink, mutationQueueLink, authLink, splitLink]) + const link = from([ + errorLink, + debounceLink, + mutationQueueLink, + authLink, + splitLink + ]) return new ApolloClient({ ssrMode, diff --git a/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.spec.tsx b/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.spec.tsx index d75275450af..d1cfe0f8a04 100644 --- a/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.spec.tsx +++ b/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.spec.tsx @@ -15,6 +15,31 @@ const meData = { } describe('checkConditionalRedirect', () => { + it('redirects to clear-auth when GetMe throws UNAUTHENTICATED', async () => { + const unauthError = { + graphQLErrors: [{ extensions: { code: 'UNAUTHENTICATED' } }] + } + const apolloClient = { + query: jest.fn().mockRejectedValueOnce(unauthError) + } as unknown as ApolloClient + expect( + await checkConditionalRedirect({ apolloClient, resolvedUrl: '/' }) + ).toEqual({ + destination: '/api/clear-auth', + permanent: false + }) + }) + + it('rethrows non-UNAUTHENTICATED errors from GetMe', async () => { + const networkError = new Error('Network error') + const apolloClient = { + query: jest.fn().mockRejectedValueOnce(networkError) + } as unknown as ApolloClient + await expect( + checkConditionalRedirect({ apolloClient, resolvedUrl: '/' }) + ).rejects.toThrow('Network error') + }) + it('calls apollo apolloClient', async () => { const apolloClient = { query: jest diff --git a/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts b/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts index ed9fbefd86d..43d43064673 100644 --- a/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts +++ b/apps/journeys-admin/src/libs/checkConditionalRedirect/checkConditionalRedirect.ts @@ -49,12 +49,32 @@ export async function checkConditionalRedirect({ redirect = `?redirect=${encodeURIComponent(resolvedUrl)}` } - const { data: me } = await apolloClient.query({ - query: GET_ME, - variables: { input: { redirect } } - }) + let meResult: GetMe | undefined + try { + const { data } = await apolloClient.query({ + query: GET_ME, + variables: { input: { redirect } } + }) + meResult = data + } catch (error) { + const isUnauthenticated = + error != null && + typeof error === 'object' && + 'graphQLErrors' in error && + Array.isArray((error as { graphQLErrors: unknown[] }).graphQLErrors) && + ( + error as { graphQLErrors: Array<{ extensions?: { code?: string } }> } + ).graphQLErrors.some((e) => e.extensions?.code === 'UNAUTHENTICATED') + + if (isUnauthenticated) { + return { destination: '/api/clear-auth', permanent: false } + } + throw error + } + + const me = meResult - if (me.me?.__typename === 'AuthenticatedUser') { + if (me?.me?.__typename === 'AuthenticatedUser') { if (!(me.me?.emailVerified ?? false)) { if (resolvedUrl.startsWith('/users/verify')) return return { @@ -64,7 +84,7 @@ export async function checkConditionalRedirect({ } } - if (me.me?.__typename === 'AnonymousUser' && allowGuest) { + if (me?.me?.__typename === 'AnonymousUser' && allowGuest) { return } diff --git a/libs/locales/en/apps-journeys-admin.json b/libs/locales/en/apps-journeys-admin.json index 5a3e18cf9a8..c36f6e0023b 100644 --- a/libs/locales/en/apps-journeys-admin.json +++ b/libs/locales/en/apps-journeys-admin.json @@ -41,6 +41,7 @@ "Customize Template": "Customize Template", "Journey not found. Redirected to templates.": "Journey not found. Redirected to templates.", "Journey Templates": "Journey Templates", + "Delete User": "Delete User", "Sign In": "Sign In", "Create New Account or Log in": "Create New Account or Log in", "A question about sign in": "A question about sign in", @@ -1067,6 +1068,22 @@ "End User License Agreement": "End User License Agreement", "Community Guidelines": "Community Guidelines", "I agree with listed above conditions and requirements": "I agree with listed above conditions and requirements", + "Something went wrong": "Something went wrong", + "An unexpected error occurred.": "An unexpected error occurred.", + "User deleted successfully": "User deleted successfully", + "User deletion failed. Check logs for details.": "User deletion failed. Check logs for details.", + "This action permanently deletes a user and their associated data. This cannot be undone. Always run a check first to review what will be deleted.": "This action permanently deletes a user and their associated data. This cannot be undone. Always run a check first to review what will be deleted.", + "Lookup By": "Lookup By", + "Database ID": "Database ID", + "User email to delete": "User email to delete", + "Database ID to delete": "Database ID to delete", + "User Email": "User Email", + "Checking...": "Checking...", + "Logs": "Logs", + "Deleting...": "Deleting...", + "Confirm User Deletion": "Confirm User Deletion", + "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.": "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.", + "Delete Permanently": "Delete Permanently", "Open conversation": "Open conversation", "Chat Platform": "Chat Platform", "Username": "Username",