Skip to content

Commit ab6b9e6

Browse files
authored
Google contacts handle invalid credentials (#2560)
* add leadminer as a tag to vcard * fix: handle missing credentials & frontend i18n tweaks * fix notification include number of exported contacts * remove useless template literal
1 parent e11d796 commit ab6b9e6

File tree

7 files changed

+142
-81
lines changed

7 files changed

+142
-81
lines changed

backend/package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"email-reply-parser": "^2.0.1",
4444
"express": "^4.21.2",
4545
"express-sse": "^1.0.0",
46+
"gaxios": "^7.1.3",
4647
"generic-pool": "^3.9.0",
4748
"googleapis": "^169.0.0",
4849
"html-entities": "^2.6.0",

backend/src/controllers/contacts.controller.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,24 @@ async function validateRequest(
3131
throw new Error(`Invalid export type: ${exportType}`);
3232
}
3333

34-
const oauthCredentials = (await miningSources.getCredentialsBySourceEmail(
35-
user.id,
36-
user.email as string
37-
)) as OAuthMiningSourceCredentials;
34+
let googleContactsOptions: ExportOptions['googleContactsOptions'] = {
35+
accessToken: undefined,
36+
refreshToken: undefined,
37+
updateEmptyFieldsOnly
38+
};
39+
40+
if (exportType === ExportType.GOOGLE_CONTACTS) {
41+
const oauthCredentials = (await miningSources.getCredentialsBySourceEmail(
42+
user.id,
43+
user.email as string
44+
)) as OAuthMiningSourceCredentials;
45+
46+
googleContactsOptions = {
47+
accessToken: oauthCredentials?.accessToken,
48+
refreshToken: oauthCredentials?.refreshToken,
49+
updateEmptyFieldsOnly
50+
};
51+
}
3852

3953
const {
4054
emails,
@@ -50,11 +64,7 @@ async function validateRequest(
5064
exportOptions: {
5165
locale: localeFromHeader,
5266
delimiter: undefined,
53-
googleContactsOptions: {
54-
accessToken: oauthCredentials?.accessToken,
55-
refreshToken: oauthCredentials?.refreshToken,
56-
updateEmptyFieldsOnly
57-
}
67+
googleContactsOptions
5868
}
5969
};
6070
}
@@ -69,11 +79,7 @@ async function validateRequest(
6979
exportOptions: {
7080
locale: localeFromHeader,
7181
delimiter: delimiterOption,
72-
googleContactsOptions: {
73-
accessToken: oauthCredentials?.accessToken,
74-
refreshToken: oauthCredentials?.refreshToken,
75-
updateEmptyFieldsOnly
76-
}
82+
googleContactsOptions
7783
}
7884
};
7985
}

backend/src/services/export/exports/googleContacts.ts

Lines changed: 80 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { google, people_v1 } from 'googleapis';
2+
import { GaxiosError } from 'gaxios';
23
import { Contact } from '../../../db/types';
34
import {
45
ExportStrategy,
@@ -15,82 +16,106 @@ export default class GoogleContactsExport implements ExportStrategy<Contact> {
1516

1617
readonly type = ExportType.GOOGLE_CONTACTS;
1718

18-
private static getPeopleService(accessToken: string, refreshToken?: string) {
19-
if (!accessToken) {
20-
throw new Error('Invalid credentials.');
21-
}
19+
private static async getPeopleService(
20+
options: ExportOptions['googleContactsOptions']
21+
) {
22+
const accessToken = options?.accessToken;
23+
const refreshToken = options?.refreshToken;
2224

23-
const oauth2Client = new google.auth.OAuth2(
24-
ENV.GOOGLE_CLIENT_ID,
25-
ENV.GOOGLE_SECRET
26-
);
25+
try {
26+
if (!accessToken && !refreshToken) {
27+
throw new Error('Invalid credentials.');
28+
}
2729

28-
oauth2Client.setCredentials({
29-
access_token: accessToken,
30-
refresh_token: refreshToken
31-
});
30+
const oauth2Client = new google.auth.OAuth2(
31+
ENV.GOOGLE_CLIENT_ID,
32+
ENV.GOOGLE_SECRET
33+
);
3234

33-
return google.people({ version: 'v1', auth: oauth2Client });
34-
}
35+
const token = googleOAuth2Client.createToken({
36+
access_token: accessToken,
37+
refresh_token: refreshToken
38+
});
3539

36-
private static validateCredentials(
37-
accessToken: string,
38-
refreshToken?: string
39-
) {
40-
const token = googleOAuth2Client.createToken({
41-
access_token: accessToken,
42-
refresh_token: refreshToken
43-
});
40+
if (token.expired(1000) && !refreshToken) {
41+
throw new Error('Invalid credentials.');
42+
}
4443

45-
if (token.expired(1000) && !refreshToken) {
46-
throw new Error('Invalid credentials.');
47-
}
44+
oauth2Client.setCredentials({
45+
access_token: accessToken,
46+
refresh_token: refreshToken
47+
});
4848

49-
return {
50-
accessToken: token.token.access_token as string,
51-
refreshToken
52-
};
49+
const peopleService = google.people({
50+
version: 'v1',
51+
auth: oauth2Client
52+
});
53+
54+
// Update cache and validate credentials
55+
await peopleService.people.searchContacts({
56+
query: '',
57+
readMask: 'names,emailAddresses,phoneNumbers,organizations,metadata'
58+
});
59+
60+
return peopleService;
61+
} catch (err) {
62+
console.log(err, accessToken, refreshToken);
63+
if (err instanceof GaxiosError) {
64+
logger.error(
65+
`Error creating google.people service: ${err.message}`,
66+
err
67+
);
68+
if (err.response?.status === 401) {
69+
throw new Error('Invalid credentials.');
70+
}
71+
} else {
72+
logger.error(
73+
`Error creating google.people service: ${(err as Error).message}`,
74+
err
75+
);
76+
}
77+
throw err;
78+
}
5379
}
5480

5581
async export(
5682
contacts: Contact[],
5783
options: ExportOptions
5884
): Promise<ExportResult> {
59-
if (!options.googleContactsOptions) {
85+
if (!options.googleContactsOptions)
6086
throw new Error('Invalid contact options.');
61-
}
6287

88+
const peopleService = await GoogleContactsExport.getPeopleService(
89+
options.googleContactsOptions
90+
);
6391
const {
64-
googleContactsOptions: {
65-
accessToken: at,
66-
refreshToken: rt,
67-
updateEmptyFieldsOnly
68-
}
92+
googleContactsOptions: { updateEmptyFieldsOnly }
6993
} = options;
70-
const { accessToken, refreshToken } =
71-
GoogleContactsExport.validateCredentials(at, rt);
72-
const peopleService = GoogleContactsExport.getPeopleService(
73-
accessToken,
74-
refreshToken
75-
);
7694
const updateEmptyOnly = updateEmptyFieldsOnly ?? false;
7795

78-
contacts.forEach(async (contact) => {
79-
const existing = await GoogleContactsExport.fetchGoogleContacts(
80-
peopleService,
81-
contact
82-
);
83-
if (existing) {
84-
await GoogleContactsExport.updateContact(
96+
/* eslint-disable no-await-in-loop */
97+
for (const contact of contacts) {
98+
try {
99+
const existing = await GoogleContactsExport.fetchGoogleContacts(
85100
peopleService,
86-
existing,
87-
contact,
88-
updateEmptyOnly
101+
contact
89102
);
90-
} else {
91-
await GoogleContactsExport.createContact(peopleService, contact);
103+
104+
if (existing) {
105+
await GoogleContactsExport.updateContact(
106+
peopleService,
107+
existing,
108+
contact,
109+
updateEmptyOnly
110+
);
111+
} else {
112+
await GoogleContactsExport.createContact(peopleService, contact);
113+
}
114+
} catch (err) {
115+
logger.error('Failed to create/update contact', err);
92116
}
93-
});
117+
}
118+
/* eslint-disable no-await-in-loop */
94119

95120
return {
96121
content: '',

backend/src/services/export/exports/vcard.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export default class VCardExport implements ExportStrategy<Contact> {
2323
private static contactToVCard(contact: Contact): string {
2424
const vcard = new VCard();
2525

26+
vcard.addCategories(['leadminer']);
27+
2628
if (contact.given_name?.length || contact.family_name?.length) {
2729
vcard.addName(contact.family_name ?? '', contact.given_name ?? '');
2830
} else if (contact.name) {

backend/src/services/export/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface ExportOptions {
88
delimiter?: string;
99
locale?: string;
1010
googleContactsOptions?: {
11-
accessToken: string;
11+
accessToken?: string;
1212
refreshToken?: string;
1313
updateEmptyFieldsOnly?: boolean;
1414
};

frontend/src/components/Mining/Buttons/ExportContacts.vue

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ import {
8181
CreditsDialogExportRef,
8282
openCreditsDialog,
8383
} from '@/utils/credits';
84+
import type { FetchError } from 'ofetch';
8485
8586
enum ExportTypes {
8687
CSV = 'csv',
@@ -220,9 +221,6 @@ async function exportTable(
220221
onResponse({ response }) {
221222
activeExport.value = false;
222223
223-
if (response.status === 401) {
224-
$consentSidebar.show('google', $profile.value?.email);
225-
}
226224
if (response.status === 402 || response.status === 266) {
227225
openCreditModel(response.status === 402, response._data);
228226
return;
@@ -234,17 +232,35 @@ async function exportTable(
234232
saveFile(response._data, filename, config.mimeType);
235233
}
236234
235+
let message;
236+
if (contactsToTreat.value === undefined) {
237+
message = $t('contacts_exported_successfully.all');
238+
} else if (contactsToTreat.value.length === 0) {
239+
message = $t('contacts_exported_successfully.none');
240+
} else if (contactsToTreat.value.length === 1) {
241+
message = $t('contacts_exported_successfully.one');
242+
} else
243+
message = $t('contacts_exported_successfully.other', {
244+
count: contactsToTreat.value.length,
245+
});
246+
237247
$toast.add({
238248
severity: 'success',
239249
summary: t(config.successSummaryKey),
240-
detail: t('contacts_exported_successfully'),
250+
detail: message,
241251
life: 8000,
242252
});
243253
}
244254
},
245255
});
246256
} catch (err) {
247257
activeExport.value = false;
258+
259+
if ((err as FetchError).response?.status === 401) {
260+
$consentSidebar.show('google', $profile.value?.email);
261+
return;
262+
}
263+
248264
throw err;
249265
}
250266
}
@@ -281,23 +297,33 @@ const exportItems = computed(() => [
281297
{
282298
"en": {
283299
"export_csv": "Export as CSV",
284-
"export_vcard": "Export as vcard",
285-
"export_google_contacts": "Export to google contacts",
286-
"confirm_google_export": "Confirm export to google contacts",
300+
"export_vcard": "Export as vcards",
301+
"export_google_contacts": "Synchronize to Google Contacts",
302+
"confirm_google_export": "Confirm export to Google Contacts",
287303
"google_export_confirmation": "Choose how your contacts should be synced with google contacts.",
288304
"update_empty_fields": "Update empty fields only",
289305
"overwrite_all_fields": "Overwrite all fields",
290-
"contacts_exported_successfully": "Contacts exported successfully"
306+
"contacts_exported_successfully": {
307+
"all": "All contacts have been exported successfully",
308+
"none": "No contacts exported",
309+
"one": "contact exported successfully",
310+
"other": "{count} contacts exported successfully"
311+
}
291312
},
292313
"fr": {
293314
"export_csv": "Exporter en CSV",
294-
"export_vcard": "Exporter en vcard",
315+
"export_vcard": "Exporter en vcards",
295316
"export_google_contacts": "Vers google contacts",
296-
"confirm_google_export": "Confirmer l’export vers Google Contacts",
317+
"confirm_google_export": "Synchroniser vers Google Contacts",
297318
"google_export_confirmation": "Choisissez comment vos contacts doivent être synchronisés avec Google Contacts.",
298319
"update_empty_fields": "Uniquement les champs vides",
299320
"overwrite_all_fields": "Tous les champs",
300-
"contacts_exported_successfully": "Contacts exportés avec succès"
321+
"contacts_exported_successfully": {
322+
"all": "Tous les contacts ont été exportés avec succès",
323+
"none": "Aucun contact exporté",
324+
"one": "contact exporté avec succès",
325+
"other": "{count} contacts exportés avec succès"
326+
}
301327
}
302328
}
303329
</i18n>

0 commit comments

Comments
 (0)