Skip to content

Commit dbae7d7

Browse files
fix(mosaic): retry failed connected accounts from their row
1 parent 4fc4673 commit dbae7d7

5 files changed

Lines changed: 78 additions & 7 deletions

File tree

‎packages/mosaic/src/features/user-profile/__tests__/user-profile-connected-accounts.feature.test.tsx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,37 @@ describe('connected accounts', () => {
132132
expect(screen.getByText('Provider error')).toBeInTheDocument();
133133
});
134134

135+
it('retries a canceled connection from its row instead of offering Connect again', async () => {
136+
const canceled = fapiExternalAccount({
137+
id: 'idn_google',
138+
provider: 'google',
139+
verification: fapiVerification({
140+
status: 'unverified',
141+
strategy: 'oauth_google',
142+
error: {
143+
code: 'oauth_access_denied',
144+
message: 'Access denied',
145+
long_message: 'You did not grant access to your Google account',
146+
},
147+
}),
148+
});
149+
const { clerk } = await renderSection([canceled]);
150+
const navigate = vi.spyOn(clerk, 'navigate').mockImplementation(() => Promise.resolve());
151+
const request = holdRequests('post', '/v1/me/external_accounts');
152+
const user = userEvent.setup();
153+
154+
expect(screen.getByText('You did not grant access to your Google account')).toBeInTheDocument();
155+
expect(screen.queryByRole('button', { name: 'Connect Google' })).toBeNull();
156+
await user.click(screen.getByRole('button', { name: 'Manage Google' }));
157+
await user.click(screen.getByRole('menuitem', { name: 'Try again' }));
158+
159+
await waitFor(() => expect(request.requests).toHaveLength(1));
160+
const body = new URLSearchParams(await request.requests[0]?.text());
161+
expect(body.get('strategy')).toBe('oauth_google');
162+
request.release();
163+
await waitFor(() => expect(navigate).toHaveBeenCalledWith('https://accounts.example/authorize'));
164+
});
165+
135166
it('sends requested scopes and the current URL to connect', async () => {
136167
serveFapi(signedIn([]));
137168
const { clerk } = await renderWithClerk(

‎packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-account-row.view.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,11 @@ export function UserProfileConnectedAccountRowView({
3131
const m = useMessages('userProfileConnectedAccounts');
3232
const iconUrl = account.iconUrl?.trim();
3333
const actions: ActionMenuAction[] = [];
34-
if (account.status === 'reconnect' && onReconnect && !isDisabled) {
35-
actions.push({ label: m.reconnect, onClick: () => onReconnect(account.id) });
34+
if ((account.status === 'reconnect' || account.status === 'error') && onReconnect && !isDisabled) {
35+
actions.push({
36+
label: account.status === 'reconnect' ? m.reconnect : m.tryAgain,
37+
onClick: () => onReconnect(account.id),
38+
});
3639
}
3740
if (onRemove && account.canRemove !== false) {
3841
actions.push({ label: m.remove, color: 'negative', onClick: () => onRemove(account) });

‎packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.messages.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const userProfileConnectedAccountsMessages = {
33
connect: 'Connect',
44
connectLabel: 'Connect {provider}',
55
reconnect: 'Reconnect',
6+
tryAgain: 'Try again',
67
disconnected: 'Disconnected',
78
manageLabel: 'Manage {provider}',
89
remove: 'Remove',

‎packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.test.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
allowsIdentificationCreation,
77
getEnabledOAuthStrategies,
88
getRecovery,
9+
getRetry,
910
projectConnectedAccounts,
1011
useUserProfileConnectedAccountsModel,
1112
} from './user-profile-connected-accounts-section.model';
@@ -281,17 +282,17 @@ describe('projectConnectedAccounts', () => {
281282
]);
282283
});
283284

284-
it('offers enabled providers without a verified account, keyed by strategy', () => {
285+
it('offers enabled providers that have no account row, keyed by strategy', () => {
285286
const projection = projectConnectedAccounts({
286287
user: userWith([
287288
account({ id: 'idn_google', provider: 'google' }),
288289
account({ id: 'idn_github', provider: 'github', status: 'unverified', errorCode: 'oauth_access_denied' }),
290+
account({ id: 'idn_acme', provider: 'custom_acme', status: 'unverified' }),
289291
]),
290292
social: social('oauth_google', 'oauth_github', 'oauth_custom_acme'),
291293
allowCreation: true,
292294
});
293295
expect(projection.status === 'ready' && projection.availableProviders.map(p => p.id)).toEqual([
294-
'oauth_github',
295296
'oauth_custom_acme',
296297
]);
297298
});
@@ -435,3 +436,24 @@ describe('allowsIdentificationCreation', () => {
435436
expect(allowsIdentificationCreation(active, { enabled: false } as EnterpriseSSOSettings)).toBe(true);
436437
});
437438
});
439+
440+
describe('getRetry', () => {
441+
it('retries a failed connection with its strategy and missing scopes', () => {
442+
expect(
443+
getRetry(
444+
account({
445+
id: 'idn_1',
446+
provider: 'google',
447+
status: 'unverified',
448+
errorCode: 'oauth_access_denied',
449+
approvedScopes: '',
450+
}),
451+
{ google: ['calendar'] },
452+
),
453+
).toEqual({ kind: 'create', strategy: 'oauth_google', additionalScopes: ['calendar'] });
454+
});
455+
456+
it('does not retry an account without an error', () => {
457+
expect(getRetry(account({ id: 'idn_1', provider: 'google' }), undefined)).toBeNull();
458+
});
459+
});

‎packages/mosaic/src/features/user-profile/user-profile-connected-accounts-section/user-profile-connected-accounts-section.model.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,20 @@ export function getRecovery(
116116
return null;
117117
}
118118

119+
return createRecovery(account, additionalScopes);
120+
}
121+
122+
export function getRetry(
123+
account: ExternalAccountResource,
124+
scopes: AdditionalOAuthScopes | undefined,
125+
): ConnectedAccountRecovery | null {
126+
if (!account.verification?.error) {
127+
return null;
128+
}
129+
return createRecovery(account, findAdditionalScopes(account, scopes));
130+
}
131+
132+
function createRecovery(account: ExternalAccountResource, additionalScopes: string[]): ConnectedAccountRecovery {
119133
const verificationStrategy = account.verification?.strategy;
120134
const strategy = (
121135
verificationStrategy === 'google_one_tap' ? 'oauth_google' : verificationStrategy || `oauth_${account.provider}`
@@ -159,14 +173,14 @@ export function projectConnectedAccounts({
159173
...user.verifiedExternalAccounts,
160174
...user.unverifiedExternalAccounts.filter(account => account.verification?.error),
161175
];
162-
const connectedStrategies = user.verifiedExternalAccounts.map(account => `oauth_${account.provider}`);
176+
const shownStrategies = displayed.map(account => `oauth_${account.provider}`);
163177

164178
return {
165179
status: 'ready',
166180
accounts: displayed.map(account => toAccountRow(account, social, additionalOAuthScopes)),
167181
availableProviders: allowCreation
168182
? getEnabledOAuthStrategies(social)
169-
.filter(strategy => !connectedStrategies.includes(strategy))
183+
.filter(strategy => !shownStrategies.includes(strategy))
170184
.map(strategy => ({ id: strategy, ...getProviderDisplay(strategy.replace('oauth_', ''), social) }))
171185
: [],
172186
};
@@ -274,7 +288,7 @@ export function useUserProfileConnectedAccountsModel({
274288
if (!account) {
275289
throw new ConnectedAccountActionError('unavailable');
276290
}
277-
const recovery = getRecovery(account, additionalOAuthScopes);
291+
const recovery = getRecovery(account, additionalOAuthScopes) ?? getRetry(account, additionalOAuthScopes);
278292
if (!recovery) {
279293
return;
280294
}

0 commit comments

Comments
 (0)