Skip to content

Commit a0afecf

Browse files
committed
🐛 Fixed staff invite error for existing users
closes [ONC-1778](https://linear.app/ghost/issue/ONC-1778/bad-error-message-when-inviting-staff-that-already-exist) Staff invite validation could miss existing users outside the first paginated users response, then show a generic failure toast even though the API returned a known duplicate-user validation error. Mapping that server validation back to the email field keeps the paginated staff list while preserving a useful form error.
1 parent 76ef180 commit a0afecf

2 files changed

Lines changed: 80 additions & 2 deletions

File tree

apps/admin-x-settings/src/components/settings/general/invite-user-modal.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import NiceModal from '@ebay/nice-modal-react';
22
import validator from 'validator';
3-
import {APIError} from '@tryghost/admin-x-framework/errors';
3+
import {APIError, ValidationError} from '@tryghost/admin-x-framework/errors';
44
import {HostLimitError, useLimiter} from '../../../hooks/use-limiter';
55
import {Modal, Radio, TextField, showToast} from '@tryghost/admin-x-design-system';
66
import {useAddInvite, useBrowseInvites} from '@tryghost/admin-x-framework/api/invites';
@@ -13,6 +13,9 @@ import {useRouting} from '@tryghost/admin-x-framework/routing';
1313

1414
type RoleType = 'administrator' | 'editor' | 'author' | 'contributor' | 'super editor';
1515

16+
const USER_ALREADY_REGISTERED_MESSAGE = 'User is already registered.';
17+
const USER_ALREADY_EXISTS_ERROR = 'A user with that email address already exists.';
18+
1619
const InviteUserModal = NiceModal.create(() => {
1720
const modal = NiceModal.useModal();
1821
const rolesQuery = useBrowseRoles();
@@ -99,7 +102,7 @@ const InviteUserModal = NiceModal.create(() => {
99102

100103
if (users?.some(({email: userEmail}) => userEmail === email)) {
101104
setErrors({
102-
email: 'A user with that email address already exists.'
105+
email: USER_ALREADY_EXISTS_ERROR
103106
});
104107
return;
105108
}
@@ -133,6 +136,14 @@ const InviteUserModal = NiceModal.create(() => {
133136
modal.remove();
134137
updateRoute('staff?tab=invited');
135138
} catch (e) {
139+
if (e instanceof ValidationError && e.data?.errors[0]?.message === USER_ALREADY_REGISTERED_MESSAGE) {
140+
setSaveState('');
141+
setErrors({
142+
email: USER_ALREADY_EXISTS_ERROR
143+
});
144+
return;
145+
}
146+
136147
setSaveState('error');
137148
let title = 'Failed to send invitation';
138149
let message = (<span>If the problem persists, <a href="https://ghost.org/contact"><u>contact support</u>.</a>.</span>);

apps/admin-x-settings/test/acceptance/general/users/invite.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,73 @@ test.describe('User invitations', async () => {
9393
});
9494
});
9595

96+
test('Shows an existing-user validation error when the user is not in the loaded page', async ({page}) => {
97+
const firstPageUsers = {
98+
...responseFixtures.users,
99+
meta: {
100+
pagination: {
101+
page: 1,
102+
limit: 100,
103+
pages: 2,
104+
total: 101,
105+
next: 2,
106+
prev: null
107+
}
108+
}
109+
};
110+
111+
const {lastApiRequests} = await mockApi({page, requests: {
112+
...globalDataRequests,
113+
browseUsers: {method: 'GET', path: '/users/?limit=100&include=roles', response: firstPageUsers},
114+
browseInvites: {method: 'GET', path: '/invites/?limit=100&include=roles', response: responseFixtures.invites},
115+
browseRoles: {method: 'GET', path: '/roles/?limit=100', response: responseFixtures.roles},
116+
browseAssignableRoles: {method: 'GET', path: '/roles/?limit=100&permissions=assign', response: responseFixtures.roles},
117+
addInvite: {
118+
method: 'POST',
119+
path: '/invites/',
120+
responseStatus: 422,
121+
responseHeaders: {
122+
'content-type': 'application/json'
123+
},
124+
response: {
125+
errors: [{
126+
id: 'validation-error',
127+
type: 'ValidationError',
128+
message: 'User is already registered.',
129+
context: null,
130+
details: null,
131+
property: null,
132+
help: null,
133+
code: null,
134+
ghostErrorCode: null
135+
}]
136+
}
137+
}
138+
}});
139+
140+
await page.goto('/');
141+
142+
const section = page.getByTestId('users');
143+
await section.getByRole('button', {name: 'Invite people'}).click();
144+
145+
const modal = page.getByTestId('invite-user-modal');
146+
await modal.getByLabel('Email address').fill('existing-user-page-2@test.com');
147+
await modal.locator('button[value=author]').click();
148+
await modal.getByRole('button', {name: 'Send invitation'}).click();
149+
150+
await expect.poll(() => lastApiRequests.addInvite?.body).toEqual({
151+
invites: [{
152+
email: 'existing-user-page-2@test.com',
153+
expires: null,
154+
role_id: '645453f3d254799990dd0e18',
155+
status: null,
156+
token: null
157+
}]
158+
});
159+
await expect(modal).toContainText('A user with that email address already exists.');
160+
await expect(page.getByTestId('toast-error')).not.toBeVisible();
161+
});
162+
96163
test('Supports resending invitations', async ({page}) => {
97164
const {lastApiRequests} = await mockApi({page, requests: {
98165
...globalDataRequests,

0 commit comments

Comments
 (0)