mirror of
https://github.com/broekema41/obsidian-vcf-contacts.git
synced 2026-07-22 05:42:58 +00:00
Refs #96: user can now fill in a clientid from google and get authenticated
This commit is contained in:
parent
d919387a9d
commit
d30ac145e8
9 changed files with 500 additions and 17 deletions
|
|
@ -13,6 +13,12 @@ export const DEFAULT_SETTINGS: ContactsPluginSettings = {
|
|||
authKey: '',
|
||||
authType: 'apikey'
|
||||
},
|
||||
GoogleContact: {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
accessToken: '',
|
||||
refreshToken: ''
|
||||
},
|
||||
createFieldsKeys: [
|
||||
"N.PREFIX",
|
||||
"N.GN",
|
||||
|
|
|
|||
8
src/settings/settings.d.ts
vendored
8
src/settings/settings.d.ts
vendored
|
|
@ -10,6 +10,7 @@ export interface ContactsPluginSettings {
|
|||
syncEnabled: boolean;
|
||||
groupInsights: boolean;
|
||||
CardDAV: CardDavSyncSettings;
|
||||
GoogleContact: GoogleContactSyncSettings;
|
||||
createFieldsKeys: string[]
|
||||
}
|
||||
|
||||
|
|
@ -21,6 +22,13 @@ interface CardDavSyncSettings {
|
|||
authType: AuthType;
|
||||
}
|
||||
|
||||
interface GoogleContactSyncSettings {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface ProcessorsSettings {
|
||||
[key: string]: string|boolean;
|
||||
}
|
||||
|
|
|
|||
240
src/sync/adapters/googleContactsAdapter.ts
Normal file
240
src/sync/adapters/googleContactsAdapter.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { getSettings } from "src/context/sharedSettingsContext";
|
||||
import { ContactsPluginSettings } from "src/settings/settings";
|
||||
import { VCardMeta, VCardRaw } from "src/sync/adapters/adapter";
|
||||
import { AdapterInterface } from "src/sync/adapters/adapterInterface";
|
||||
import { CarddavSettingsInterface } from "src/ui/settings/components/carddavSettings";
|
||||
import { AppHttpResponse, PlatformHttpClient } from "src/util/platformHttpClient";
|
||||
import { fnOutOfString, uidOutOfString } from "src/util/vcard";
|
||||
|
||||
|
||||
export function googleContactsAdapter(): AdapterInterface {
|
||||
|
||||
const getAuthHeader = (settings:ContactsPluginSettings) => {
|
||||
if (settings.CardDAV.authType === 'apikey') {
|
||||
return `Bearer ${settings.CardDAV.authKey}`;
|
||||
} else {
|
||||
return `Basic ${settings.CardDAV.authKey}`;
|
||||
}
|
||||
}
|
||||
|
||||
const checkConnectivity = async (settings: CarddavSettingsInterface): Promise<AppHttpResponse> => {
|
||||
const headers = { Authorization : ''};
|
||||
if (settings.authKey) {
|
||||
headers.Authorization = `Bearer ${settings.authKey}`;
|
||||
} else {
|
||||
headers.Authorization = 'Basic ' + btoa(settings.username + ":" + settings.password);
|
||||
}
|
||||
return await PlatformHttpClient.request({
|
||||
url: settings.addressBookUrl,
|
||||
method: 'OPTIONS',
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
const isVcfNode = (response: Element): boolean => {
|
||||
const href = response.querySelector('href')?.textContent;
|
||||
const successPropstat = Array.from(response.querySelectorAll('propstat')).find(
|
||||
propstat => propstat.querySelector('status')?.textContent?.includes('200 OK')
|
||||
);
|
||||
|
||||
if (!successPropstat || !href || !href.endsWith('.vcf')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
const getMetaByUid = async (uid: string): Promise<VCardMeta | AppHttpResponse | undefined> => {
|
||||
const settings = getSettings();
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag />
|
||||
<D:getlastmodified />
|
||||
<D:resourcetype />
|
||||
<C:address-data>
|
||||
<C:prop name="UID"/>
|
||||
<C:prop name="FN"/>
|
||||
</C:address-data>
|
||||
</D:prop>
|
||||
<C:filter>
|
||||
<C:prop-filter name="UID">
|
||||
<C:text-match match-type="contains">${uid}</C:text-match>
|
||||
</C:prop-filter>
|
||||
</C:filter>
|
||||
</C:addressbook-query>`;
|
||||
|
||||
const res = await PlatformHttpClient.request({
|
||||
url: settings.CardDAV.addressBookUrl,
|
||||
method: 'REPORT',
|
||||
headers: {
|
||||
'Authorization': getAuthHeader(settings),
|
||||
'Content-Type': 'application/xml',
|
||||
'Depth': '1'
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if(res.errorMessage) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const xml = parser.parseFromString(res.data, 'application/xml');
|
||||
const responses = xml.querySelectorAll('response');
|
||||
|
||||
if(!responses || responses.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = responses[0];
|
||||
|
||||
if (!isVcfNode(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = response.querySelector('href')?.textContent || '';
|
||||
const etag = response.querySelector('getetag')?.textContent || '';
|
||||
const lastModified = response.querySelector('getlastmodified')?.textContent || '';
|
||||
const adressData = response.querySelector('address-data')?.textContent || '';
|
||||
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
href,
|
||||
etag: etag?.replace(/"/g, '') || '', // Remove quotes from etag
|
||||
lastModified: lastModified ? new Date(lastModified) : new Date(),
|
||||
uid: uidOutOfString(adressData),
|
||||
fn: fnOutOfString(adressData)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const getMetaList = async (): Promise<VCardMeta[] | AppHttpResponse> => {
|
||||
const settings = getSettings();
|
||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
|
||||
<D:prop>
|
||||
<D:getetag />
|
||||
<D:getlastmodified />
|
||||
<D:resourcetype />
|
||||
<C:address-data>
|
||||
<C:prop name="UID"/>
|
||||
<C:prop name="FN"/>
|
||||
</C:address-data>
|
||||
</D:prop>
|
||||
</C:addressbook-query>`;
|
||||
|
||||
const res = await PlatformHttpClient.request({
|
||||
url: settings.CardDAV.addressBookUrl ,
|
||||
method: 'REPORT',
|
||||
headers: {
|
||||
'Authorization': getAuthHeader(settings),
|
||||
'Content-Type': 'application/xml',
|
||||
'Depth': '1'
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if(res.errorMessage) {
|
||||
return res;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const xml = parser.parseFromString(res.data, 'application/xml');
|
||||
|
||||
const responses = xml.querySelectorAll('response');
|
||||
const vcardMetas: VCardMeta[] = [];
|
||||
|
||||
responses.forEach(response => {
|
||||
if(!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVcfNode(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const href = response.querySelector('href')?.textContent;
|
||||
const etag = response.querySelector('getetag')?.textContent;
|
||||
const lastModified = response.querySelector('getlastmodified')?.textContent;
|
||||
const adressData = response.querySelector('address-data')?.textContent || '';
|
||||
|
||||
if (!href) {
|
||||
return;
|
||||
}
|
||||
|
||||
vcardMetas.push({
|
||||
href,
|
||||
etag: etag?.replace(/"/g, '') || '', // Remove quotes from etag
|
||||
lastModified: lastModified ? new Date(lastModified) : new Date(),
|
||||
uid: uidOutOfString(adressData),
|
||||
fn: fnOutOfString(adressData)
|
||||
});
|
||||
});
|
||||
|
||||
return vcardMetas;
|
||||
}
|
||||
|
||||
|
||||
const pull = async (href: string): Promise<VCardRaw | AppHttpResponse> => {
|
||||
const settings = getSettings();
|
||||
const vcfUrl = new URL(href, settings.CardDAV.addressBookUrl).toString();
|
||||
const res = await PlatformHttpClient.request({
|
||||
url: vcfUrl,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: getAuthHeader(settings),
|
||||
['Accept']: 'text/vcard; version=4.0; charset=utf-8;'
|
||||
}
|
||||
});
|
||||
|
||||
if(res.errorMessage) {
|
||||
return res;
|
||||
}
|
||||
|
||||
return {
|
||||
uid: uidOutOfString(res.data),
|
||||
raw: res.data,
|
||||
}
|
||||
}
|
||||
|
||||
const push = async (vcard: VCardRaw): Promise<AppHttpResponse> => {
|
||||
const settings = getSettings();
|
||||
const vcfUrl = settings.CardDAV.addressBookUrl + `/${vcard.uid}.vcf`;
|
||||
return await PlatformHttpClient.request({
|
||||
url: vcfUrl,
|
||||
method: 'PUT',
|
||||
body: vcard.raw,
|
||||
headers: {
|
||||
['Authorization']: getAuthHeader(settings),
|
||||
['Content-Type']: 'text/vcard; version=4.0; charset=utf-8;'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const deleteContact = async (href: string): Promise<AppHttpResponse> => {
|
||||
const settings = getSettings();
|
||||
const vcfUrl = new URL(href, settings.CardDAV.addressBookUrl).toString();
|
||||
return await PlatformHttpClient.request({
|
||||
url: vcfUrl,
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: getAuthHeader(settings),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
checkConnectivity,
|
||||
getMetaByUid,
|
||||
getMetaList,
|
||||
pull,
|
||||
push,
|
||||
delete: deleteContact
|
||||
};
|
||||
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { settings } from "src/context/sharedSettingsContext";
|
||||
import { carddavGenericAdapter } from "src/sync/adapters/carddavGeneric";
|
||||
import { googleContactsAdapter } from "src/sync/adapters/googleContactsAdapter";
|
||||
|
||||
export const adapters = {
|
||||
None: undefined,
|
||||
CardDAV: carddavGenericAdapter()
|
||||
CardDAV: carddavGenericAdapter(),
|
||||
GoogleContacts: googleContactsAdapter()
|
||||
}
|
||||
|
||||
export function getCurrentAdapter() {
|
||||
|
|
|
|||
70
src/sync/auth/callbackServer.ts
Normal file
70
src/sync/auth/callbackServer.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
|
||||
|
||||
export interface OAuthCallbackData {
|
||||
code?: string;
|
||||
state?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type Request = {
|
||||
url?: string;
|
||||
method?: string;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
};
|
||||
|
||||
type Response = {
|
||||
writeHead(status: number, headers?: Record<string, string>): void;
|
||||
end(data?: string): void;
|
||||
};
|
||||
|
||||
const CALLBACK_PORT = 37288;
|
||||
|
||||
const protocol = 'node:http';
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires,@typescript-eslint/no-require-imports
|
||||
const http = require(protocol);
|
||||
// @ts-ignore
|
||||
let server: http.Server | undefined;
|
||||
|
||||
export function startServer(
|
||||
callback: (data: OAuthCallbackData) => void
|
||||
): void {
|
||||
|
||||
server = http.createServer((req: Request , res: Response) => {
|
||||
|
||||
const url = new URL(
|
||||
req.url ?? "",
|
||||
`http://127.0.0.1:${CALLBACK_PORT}`
|
||||
);
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html",
|
||||
});
|
||||
|
||||
res.end(`
|
||||
<html>
|
||||
<body>
|
||||
<h2>Authentication callback received</h2>
|
||||
<p>You may close this window.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
callback({
|
||||
code: url.searchParams.get("code") ?? undefined,
|
||||
state: url.searchParams.get("state") ?? undefined,
|
||||
error: url.searchParams.get("error") ?? undefined,
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(CALLBACK_PORT, "127.0.0.1");
|
||||
}
|
||||
|
||||
export function stopServer(): void {
|
||||
|
||||
if (!server) {
|
||||
return;
|
||||
}
|
||||
|
||||
server.close();
|
||||
server = undefined;
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
import { shell } from "electron";
|
||||
import {getSettings} from "src/context/sharedSettingsContext";
|
||||
import {OAuthCallbackData, startServer, stopServer} from "src/sync/auth/callbackServer";
|
||||
import {PlatformHttpClient} from "src/util/platformHttpClient";
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { shell } = require("electron");
|
||||
|
||||
export interface OAuthConfig {
|
||||
clientId: string;
|
||||
authorizationEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
scopes: string[];
|
||||
|
|
@ -13,8 +16,15 @@ export interface OAuthConfig {
|
|||
redirectUrl: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
token_type?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export const GOOGLE_OAUTH_CONFIG: OAuthConfig = {
|
||||
clientId:'developement',
|
||||
authorizationEndpoint:
|
||||
'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
tokenEndpoint:
|
||||
|
|
@ -35,9 +45,9 @@ export function buildAuthorizationUrl(
|
|||
state: string,
|
||||
codeChallenge: string
|
||||
): string {
|
||||
|
||||
const settings = getSettings();
|
||||
const params = new URLSearchParams({
|
||||
client_id: GOOGLE_OAUTH_CONFIG.clientId,
|
||||
client_id: settings.GoogleContact.clientId,
|
||||
redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUrl,
|
||||
response_type: GOOGLE_OAUTH_CONFIG.responseType,
|
||||
scope: GOOGLE_OAUTH_CONFIG.scopes.join(' '),
|
||||
|
|
@ -86,16 +96,79 @@ export function generatePkceCodeVerifier(length = 64): string {
|
|||
.join('');
|
||||
}
|
||||
|
||||
export async function login() {
|
||||
const codeVerifier = generatePkceCodeVerifier();
|
||||
const codeChallenge = await generatePkceCodeChallenge(codeVerifier);
|
||||
const state = crypto.randomUUID();
|
||||
const authUrl = buildAuthorizationUrl(state, codeChallenge);
|
||||
|
||||
await shell.openExternal(authUrl);
|
||||
async function exchangeCodeForToken(
|
||||
code: string,
|
||||
verifier: string
|
||||
): Promise<TokenResponse> {
|
||||
const settings = getSettings();
|
||||
const response = await PlatformHttpClient.request({
|
||||
url: GOOGLE_OAUTH_CONFIG.tokenEndpoint,
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: settings.GoogleContact.clientId,
|
||||
code,
|
||||
client_secret: settings.GoogleContact.clientSecret,
|
||||
redirect_uri: GOOGLE_OAUTH_CONFIG.redirectUrl,
|
||||
code_verifier: verifier
|
||||
}).toString(),
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
codeVerifier,
|
||||
state,
|
||||
};
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
throw new Error(
|
||||
`Token exchange failed (${response.status}): ${response.data}`
|
||||
);
|
||||
}
|
||||
|
||||
return JSON.parse(response.data) as TokenResponse;
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export function login(): Promise<LoginResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const run = async () => {
|
||||
try {
|
||||
const codeVerifier = generatePkceCodeVerifier();
|
||||
const codeChallenge = await generatePkceCodeChallenge(codeVerifier);
|
||||
const state = crypto.randomUUID();
|
||||
const authUrl = buildAuthorizationUrl(state, codeChallenge);
|
||||
|
||||
startServer(async (data: OAuthCallbackData) => {
|
||||
try {
|
||||
stopServer();
|
||||
|
||||
if (!data.code) {
|
||||
throw new Error("OAuth callback did not include a code.");
|
||||
}
|
||||
|
||||
const tokens = await exchangeCodeForToken(data.code, codeVerifier);
|
||||
|
||||
resolve({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token ?? "",
|
||||
expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
await shell.openExternal(authUrl);
|
||||
} catch (err) {
|
||||
stopServer();
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
55
src/ui/settings/components/googleContactSettings.tsx
Normal file
55
src/ui/settings/components/googleContactSettings.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import * as React from "react";
|
||||
|
||||
export interface GoogleContactSettingsInterface {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
interface GoogleContactSettingsProps {
|
||||
googleContactSettings: GoogleContactSettingsInterface;
|
||||
setGoogleContactSettings: (settings: GoogleContactSettingsInterface) => void;
|
||||
}
|
||||
|
||||
export default function GoogleContactSettings({ googleContactSettings, setGoogleContactSettings }: GoogleContactSettingsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">Client Id</div>
|
||||
<div className="setting-item-description">
|
||||
Oauth client id from google cloud
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
className="textfield"
|
||||
type="text"
|
||||
placeholder="ClientId"
|
||||
value={googleContactSettings.clientId}
|
||||
onChange={e => setGoogleContactSettings({ ...googleContactSettings, clientId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item">
|
||||
<div className="setting-item-info">
|
||||
<div className="setting-item-name">Client Secret</div>
|
||||
<div className="setting-item-description">
|
||||
auth client secret from google cloud.
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-item-control">
|
||||
<input
|
||||
className="textfield"
|
||||
type="password"
|
||||
placeholder="clientSecret"
|
||||
value={googleContactSettings.clientSecret}
|
||||
onChange={e => setGoogleContactSettings({ ...googleContactSettings, clientSecret: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {SyncSelected} from "src/settings/settings";
|
|||
import {carddavGenericAdapter} from "src/sync/adapters/carddavGeneric";
|
||||
import CarddavSettings from "src/ui/settings/components/carddavSettings";
|
||||
import {oauth2} from "src/sync/auth";
|
||||
import GoogleContactsSettings from "src/ui/settings/components/googleContactSettings";
|
||||
|
||||
const initCardavSettings = {
|
||||
addressBookUrl: "",
|
||||
|
|
@ -14,6 +15,13 @@ const initCardavSettings = {
|
|||
authKey: ""
|
||||
};
|
||||
|
||||
const initGoogleContactSettings = {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
accessToken: "",
|
||||
refreshToken: "",
|
||||
};
|
||||
|
||||
export function SynchronizationSettings() {
|
||||
const isDisabled = Platform.isMobileApp;
|
||||
const [warning, setWarning] = useState('');
|
||||
|
|
@ -24,6 +32,11 @@ export function SynchronizationSettings() {
|
|||
...initCardavSettings,
|
||||
addressBookUrl: initSettings.CardDAV.addressBookUrl
|
||||
});
|
||||
const [googleContactSettings, setGoogleContactSettings] = useState({
|
||||
...initGoogleContactSettings,
|
||||
clientId: initSettings.GoogleContact.clientId,
|
||||
clientSecret: initSettings.GoogleContact.clientSecret
|
||||
});
|
||||
|
||||
const enableSync = async () => {
|
||||
if (!['CardDAV', 'GoogleContacts'].includes(syncSelected)) {
|
||||
|
|
@ -78,7 +91,11 @@ export function SynchronizationSettings() {
|
|||
|
||||
const enableGoogleContacts = async () => {
|
||||
try {
|
||||
await oauth2.login();
|
||||
await updateSetting('GoogleContact.clientId', googleContactSettings.clientId)
|
||||
await updateSetting('GoogleContact.clientSecret', googleContactSettings.clientSecret)
|
||||
const successExchanged = await oauth2.login(); // TODO: handle failure scenarios.
|
||||
await updateSetting('GoogleContact.accessToken', successExchanged.accessToken)
|
||||
await updateSetting('GoogleContact.refreshToken', successExchanged.refreshToken)
|
||||
console.log('aaaaaaaa');
|
||||
} catch (err: any) {
|
||||
setWarning(`failed to enable connection!`);
|
||||
|
|
@ -186,6 +203,12 @@ export function SynchronizationSettings() {
|
|||
setCarddavSettings={setCarddavSettings}
|
||||
/>
|
||||
)}
|
||||
{syncSelected === "GoogleContacts" && (
|
||||
<GoogleContactsSettings
|
||||
googleContactSettings={googleContactSettings}
|
||||
setGoogleContactSettings={setGoogleContactSettings}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ const mockSettings: ContactsPluginSettings = {
|
|||
authKey: '',
|
||||
authType: 'apikey'
|
||||
},
|
||||
GoogleContact: {
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
accessToken: '',
|
||||
refreshToken: ''
|
||||
},
|
||||
createFieldsKeys:['N.FN']
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue