diff --git a/frontend/src/components/admin/InlineCustomerCreate.tsx b/frontend/src/components/admin/InlineCustomerCreate.tsx index 12ba71a1..fc012449 100644 --- a/frontend/src/components/admin/InlineCustomerCreate.tsx +++ b/frontend/src/components/admin/InlineCustomerCreate.tsx @@ -189,15 +189,34 @@ export const InlineCustomerCreate: React.FC = ({ onCreated, onCancel, mod toast.success(t('customers.create.savedActiveToast', 'Customer created and portal invitation queued. It is sent by the email queue — check System health if it does not arrive.')); } catch (err: any) { - // A 409 means an invitation for this address is already open, which - // is a different thing from the email failing: the customer is - // invited, and re-inviting is what was refused. - const alreadyInvited = err?.response?.status === 409; - toast.warn(alreadyInvited - ? t('customers.create.inviteAlreadyPendingToast', - 'Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one.') - : t('customers.create.inviteFailedToast', + // Three different things reach this branch and they need three + // different answers. Only one of them means "no invitation exists". + const status = err?.response?.status; + const code = err?.response?.data?.code; + + if (status === 409 && code === 'CUSTOMER_ALREADY_ACTIVE') { + // An open invitation for this address was accepted between + // createDirect and sendInvite. The customer has portal access + // already, so there is nothing to cancel and nothing to resend. + toast.info(t('customers.create.inviteAlreadyActiveToast', + 'Customer saved. This address already has portal access, so no invitation was needed.')); + } else if (status === 409) { + // An invitation for this address is already open and the RE-invite + // was refused. The customer IS invited; telling them to retry + // would send them the wrong way. + toast.warn(t('customers.create.inviteAlreadyPendingToast', + 'Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one.')); + } else if (!err?.response) { + // No response at all: a dropped connection or a timeout. The + // request may well have succeeded server-side, so claiming it + // failed sends the admin into a retry that then 409s. Say what is + // actually known and point at where the answer is. + toast.warn(t('customers.create.inviteUnconfirmedToast', + 'Customer saved, but the invitation could not be confirmed — the server did not answer. Check the Invitations tab before sending another.')); + } else { + toast.warn(t('customers.create.inviteFailedToast', 'Customer saved as PASSIVE — no invitation went out. Retry "Send portal invitation" from the customer detail page.')); + } // eslint-disable-next-line no-console console.warn('sendInvite failed', err); } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9e88d8d5..7926a35b 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -4852,7 +4852,9 @@ "inviteFailedToast": "Kunde als PASSIV gespeichert — es ging keine Einladung raus. Wiederholen Sie „Portal-Einladung senden“ auf der Kundendetailseite.", "emailRequired": "Eine gültige E-Mail-Adresse ist erforderlich.", "nameRequired": "Geben Sie mindestens einen Firmennamen oder einen Ansprechpartner an.", - "inviteAlreadyPendingToast": "Kunde gespeichert. Für diese Adresse ist bereits eine Einladung offen — stornieren Sie sie im Tab „Einladungen“, bevor Sie eine neue senden." + "inviteAlreadyPendingToast": "Kunde gespeichert. Für diese Adresse ist bereits eine Einladung offen — stornieren Sie sie im Tab „Einladungen“, bevor Sie eine neue senden.", + "inviteAlreadyActiveToast": "Kunde gespeichert. Diese Adresse hat bereits Portalzugang, eine Einladung war nicht nötig.", + "inviteUnconfirmedToast": "Kunde gespeichert, aber die Einladung ist unbestätigt — der Server hat nicht geantwortet. Prüfen Sie den Tab „Einladungen“, bevor Sie erneut senden." }, "passive": { "badge": "Passiv — nur Admin", @@ -5049,7 +5051,7 @@ }, "invitePending": { "badge": "Einladung offen", - "hint": "Einladung verschickt, noch nicht angenommen. Der Kunde bleibt passiv, bis er ein Passwort gesetzt hat." + "hint": "Für diese Adresse ist ein Einladungslink offen und noch nicht angenommen. Das ist kein Beleg dafür, dass die E-Mail angekommen ist — prüfen Sie den Systemzustand, falls der Kunde nichts erhalten hat." } }, "clients": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index dfec152b..c7fcab28 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -4852,7 +4852,9 @@ "inviteFailedToast": "Customer saved as PASSIVE — no invitation went out. Retry \"Send portal invitation\" from the customer detail page.", "emailRequired": "A valid email is required.", "nameRequired": "Enter at least a company name or a contact name.", - "inviteAlreadyPendingToast": "Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one." + "inviteAlreadyPendingToast": "Customer saved. An invitation for this address is already open — cancel it on the Invitations tab before sending a new one.", + "inviteAlreadyActiveToast": "Customer saved. This address already has portal access, so no invitation was needed.", + "inviteUnconfirmedToast": "Customer saved, but the invitation could not be confirmed — the server did not answer. Check the Invitations tab before sending another." }, "passive": { "badge": "Passive — admin only", @@ -5049,7 +5051,7 @@ }, "invitePending": { "badge": "Invitation pending", - "hint": "Invitation sent, not accepted yet. The customer stays passive until they set a password." + "hint": "An invitation link for this address is open and has not been accepted. That is not proof the email reached them — check System health if they say it never arrived." } }, "clients": { diff --git a/frontend/src/pages/admin/CustomerManagementPage.tsx b/frontend/src/pages/admin/CustomerManagementPage.tsx index 0b8d9fad..1f861b22 100644 --- a/frontend/src/pages/admin/CustomerManagementPage.tsx +++ b/frontend/src/pages/admin/CustomerManagementPage.tsx @@ -256,8 +256,13 @@ export const CustomerManagementPage: React.FC = () => { return invite ? ( {t('customers.invitePending.badge', 'Invitation pending')} diff --git a/frontend/src/pages/admin/__tests__/customerInvitationVisibility.test.tsx b/frontend/src/pages/admin/__tests__/customerInvitationVisibility.test.tsx index 6e8a7036..a52dcfae 100644 --- a/frontend/src/pages/admin/__tests__/customerInvitationVisibility.test.tsx +++ b/frontend/src/pages/admin/__tests__/customerInvitationVisibility.test.tsx @@ -29,6 +29,7 @@ vi.mock('react-toastify', () => ({ import { toast } from 'react-toastify'; const toastSuccess = toast.success as ReturnType; const toastWarn = toast.warn as ReturnType; +const toastInfo = toast.info as ReturnType; vi.mock('../../../contexts/PermissionsContext', () => ({ usePermissions: () => ({ @@ -190,4 +191,57 @@ describe('InlineCustomerCreate — what the toast may claim (#1261)', () => { expect(message).toMatch(/already open/i); expect(message).not.toMatch(/PASSIVE/); }); + + it('separates CUSTOMER_ALREADY_ACTIVE from a pending-invitation 409', async () => { + // Codex review round 1. Both conflicts are 409. This one means an open + // invitation was accepted between createDirect and sendInvite, so there is + // no invitation row to cancel — sending the admin to the Invitations tab + // points them at something that does not exist. + createDirect.mockResolvedValue({ id: 8, email: 'new@example.com' }); + sendInvite.mockRejectedValue({ + response: { status: 409, data: { code: 'CUSTOMER_ALREADY_ACTIVE' } }, + }); + + renderWith( {}} onCancel={() => {}} />); + await fill(); + await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i })); + + await waitFor(() => expect(toastInfo).toHaveBeenCalled()); + const message = String(toastInfo.mock.calls[0][0]); + expect(message).toMatch(/already has portal access/i); + expect(message).not.toMatch(/Invitations tab/i); + expect(toastWarn).not.toHaveBeenCalled(); + }); + + it('stays indeterminate when the server never answered', async () => { + // Codex review round 1. A dropped connection or timeout rejects with no + // `response`, but the request may have succeeded server-side. Declaring + // "no invitation went out" sends the admin into a retry that then 409s. + createDirect.mockResolvedValue({ id: 9, email: 'new@example.com' }); + sendInvite.mockRejectedValue(new Error('Network Error')); + + renderWith( {}} onCancel={() => {}} />); + await fill(); + await userEvent.click(screen.getByRole('button', { name: /Save & send portal invitation/i })); + + await waitFor(() => expect(toastWarn).toHaveBeenCalled()); + const message = String(toastWarn.mock.calls[0][0]); + expect(message).toMatch(/could not be confirmed/i); + expect(message).not.toMatch(/PASSIVE/); + }); + + it('does not claim the invitation email was delivered', async () => { + // createInvitation inserts the customer_invitations row and only then + // queues the email, without a transaction — so an open invitation is not + // evidence that an email_queue row exists, let alone that it was sent. + list.mockResolvedValue([customer(10, 'invited@example.com')]); + listInvitations.mockResolvedValue([invitation(20, 'invited@example.com')]); + + renderWith(); + + const badge = await screen.findByText('Invitation pending'); + const tooltip = badge.getAttribute('title') || badge.closest('[title]')?.getAttribute('title') || ''; + expect(tooltip).not.toMatch(/invitation sent/i); + expect(tooltip).toMatch(/not proof/i); + }); });