Google-scopes-handling (#12362)
# Summary Enhanced the Google OAuth flow to better handle missing permissions and improved user experience by redirecting to settings/account page. ## Changes - Added new google-apis-scopes.ts service for better scope management - Updated Google APIs auth controller for better flow control - New tests for this logic ## User request From @bonapara email test and need to better handle user flow during the connect email flow Before : <img width="574" alt="Screenshot 2025-05-28 at 17 58 59" src="https://github.com/user-attachments/assets/fd54625b-e211-4b2f-b76a-48bcb08b5222" /> After : <img width="1143" alt="Screenshot 2025-05-28 at 16 29 05" src="https://github.com/user-attachments/assets/8f3d1f2c-9e02-4d25-b949-fe2b20f048f4" /> ## Reference : For google specialities, I added this link in the `export const getGoogleApisOauthScopes` in order to keep that in mind https://developers.google.com/identity/protocols/oauth2/scopes
This commit is contained in:
@ -17,6 +17,7 @@ import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/servi
|
|||||||
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
|
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
|
||||||
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
|
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
|
||||||
import { CreateMessageFolderService } from 'src/engine/core-modules/auth/services/create-message-folder.service';
|
import { CreateMessageFolderService } from 'src/engine/core-modules/auth/services/create-message-folder.service';
|
||||||
|
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
|
||||||
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
|
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
|
||||||
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
|
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
|
||||||
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
|
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
|
||||||
@ -115,6 +116,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
|
|||||||
SamlAuthStrategy,
|
SamlAuthStrategy,
|
||||||
AuthResolver,
|
AuthResolver,
|
||||||
GoogleAPIsService,
|
GoogleAPIsService,
|
||||||
|
GoogleAPIScopesService,
|
||||||
MicrosoftAPIsService,
|
MicrosoftAPIsService,
|
||||||
AppTokenService,
|
AppTokenService,
|
||||||
AccessTokenService,
|
AccessTokenService,
|
||||||
|
|||||||
@ -126,7 +126,7 @@ export class GoogleAPIsAuthController {
|
|||||||
subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
|
subdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
|
||||||
customDomain: null,
|
customDomain: null,
|
||||||
},
|
},
|
||||||
pathname: '/verify',
|
pathname: '/settings/accounts',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,113 @@
|
|||||||
|
import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
|
||||||
|
|
||||||
|
import { includesExpectedScopes } from './google-apis-scopes.service.util';
|
||||||
|
|
||||||
|
describe('GoogleAPIScopesService', () => {
|
||||||
|
describe('includesExpectedScopes', () => {
|
||||||
|
it('should return true when all expected scopes are present', () => {
|
||||||
|
const scopes = [
|
||||||
|
'email',
|
||||||
|
'profile',
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly',
|
||||||
|
'https://www.googleapis.com/auth/calendar.events',
|
||||||
|
];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when some expected scopes are missing', () => {
|
||||||
|
const scopes = ['email', 'profile'];
|
||||||
|
const expectedScopes = [
|
||||||
|
'email',
|
||||||
|
'profile',
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly',
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when expected scopes match with userinfo prefix fallback', () => {
|
||||||
|
const scopes = [
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when some scopes are direct matches and others use userinfo prefix', () => {
|
||||||
|
const scopes = [
|
||||||
|
'email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly',
|
||||||
|
];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when 0 expected scopes', () => {
|
||||||
|
const scopes = ['email', 'profile'];
|
||||||
|
const expectedScopes: string[] = [];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when 0 scopes but expected scopes', () => {
|
||||||
|
const scopes: string[] = [];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when both empty', () => {
|
||||||
|
const scopes: string[] = [];
|
||||||
|
const expectedScopes: string[] = [];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case-sensitive scope matching', () => {
|
||||||
|
const scopes = ['EMAIL', 'PROFILE'];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with the current Google API scopes', () => {
|
||||||
|
// What is currently returned by Google
|
||||||
|
const actualGoogleScopes = [
|
||||||
|
'https://www.googleapis.com/auth/calendar.events',
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly',
|
||||||
|
'https://www.googleapis.com/auth/gmail.send',
|
||||||
|
'https://www.googleapis.com/auth/profile.emails.read',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
'openid',
|
||||||
|
];
|
||||||
|
const expectedScopes = getGoogleApisOauthScopes();
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(actualGoogleScopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,113 @@
|
|||||||
|
import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
|
||||||
|
|
||||||
|
import { includesExpectedScopes } from './google-apis-scopes.service.util';
|
||||||
|
|
||||||
|
describe('GoogleAPIScopesService', () => {
|
||||||
|
describe('includesExpectedScopes', () => {
|
||||||
|
it('should return true when all expected scopes are present', () => {
|
||||||
|
const scopes = [
|
||||||
|
'email',
|
||||||
|
'profile',
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly',
|
||||||
|
'https://www.googleapis.com/auth/calendar.events',
|
||||||
|
];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when some expected scopes are missing', () => {
|
||||||
|
const scopes = ['email', 'profile'];
|
||||||
|
const expectedScopes = [
|
||||||
|
'email',
|
||||||
|
'profile',
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly',
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when expected scopes match with userinfo prefix fallback', () => {
|
||||||
|
const scopes = [
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when some scopes are direct matches and others use userinfo prefix', () => {
|
||||||
|
const scopes = [
|
||||||
|
'email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly',
|
||||||
|
];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when 0 expected scopes', () => {
|
||||||
|
const scopes = ['email', 'profile'];
|
||||||
|
const expectedScopes: string[] = [];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when 0 scopes but expected scopes', () => {
|
||||||
|
const scopes: string[] = [];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when both empty', () => {
|
||||||
|
const scopes: string[] = [];
|
||||||
|
const expectedScopes: string[] = [];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case-sensitive scope matching', () => {
|
||||||
|
const scopes = ['EMAIL', 'PROFILE'];
|
||||||
|
const expectedScopes = ['email', 'profile'];
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(scopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with the current Google API scopes', () => {
|
||||||
|
// What is currently returned by Google
|
||||||
|
const actualGoogleScopes = [
|
||||||
|
'https://www.googleapis.com/auth/calendar.events',
|
||||||
|
'https://www.googleapis.com/auth/gmail.readonly',
|
||||||
|
'https://www.googleapis.com/auth/gmail.send',
|
||||||
|
'https://www.googleapis.com/auth/profile.emails.read',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
'openid',
|
||||||
|
];
|
||||||
|
const expectedScopes = getGoogleApisOauthScopes();
|
||||||
|
|
||||||
|
const result = includesExpectedScopes(actualGoogleScopes, expectedScopes);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
export const includesExpectedScopes = (
|
||||||
|
scopes: string[],
|
||||||
|
expectedScopes: string[],
|
||||||
|
): boolean => {
|
||||||
|
return expectedScopes.every(
|
||||||
|
(expectedScope) =>
|
||||||
|
scopes.includes(expectedScope) ||
|
||||||
|
scopes.includes(
|
||||||
|
`https://www.googleapis.com/auth/userinfo.${expectedScope}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import { HttpService } from '@nestjs/axios';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuthException,
|
||||||
|
AuthExceptionCode,
|
||||||
|
} from 'src/engine/core-modules/auth/auth.exception';
|
||||||
|
import { includesExpectedScopes } from 'src/engine/core-modules/auth/services/google-apis-scopes.service.util';
|
||||||
|
import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
|
||||||
|
|
||||||
|
interface TokenInfoResponse {
|
||||||
|
scope: string;
|
||||||
|
exp: string;
|
||||||
|
email: string;
|
||||||
|
email_verified: string;
|
||||||
|
access_type: string;
|
||||||
|
client_id: string;
|
||||||
|
user_id: string;
|
||||||
|
aud: string;
|
||||||
|
azp: string;
|
||||||
|
sub: string;
|
||||||
|
hd: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleAPIScopesService {
|
||||||
|
constructor(private httpService: HttpService) {}
|
||||||
|
|
||||||
|
public async getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent(
|
||||||
|
accessToken: string,
|
||||||
|
): Promise<{ scopes: string[]; isValid: boolean }> {
|
||||||
|
try {
|
||||||
|
const response = await this.httpService.axiosRef.get<TokenInfoResponse>(
|
||||||
|
`https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=${accessToken}`,
|
||||||
|
{ timeout: 600 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const scopes = response.data.scope.split(' ');
|
||||||
|
const expectedScopes = getGoogleApisOauthScopes();
|
||||||
|
|
||||||
|
return {
|
||||||
|
scopes,
|
||||||
|
isValid: includesExpectedScopes(scopes, expectedScopes),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
throw new AuthException(
|
||||||
|
'Google account connect error: cannot read scopes from token',
|
||||||
|
AuthExceptionCode.INSUFFICIENT_SCOPES,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
|
|||||||
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
|
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
|
||||||
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
|
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
|
||||||
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
|
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
|
||||||
|
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
|
||||||
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
|
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
|
||||||
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
|
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
|
||||||
import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service';
|
import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service';
|
||||||
@ -114,6 +115,16 @@ describe('GoogleAPIsService', () => {
|
|||||||
resetCalendarChannels: jest.fn(),
|
resetCalendarChannels: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: GoogleAPIScopesService,
|
||||||
|
useValue: {
|
||||||
|
getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent:
|
||||||
|
jest.fn().mockResolvedValue({
|
||||||
|
scopes: [],
|
||||||
|
isValid: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: ResetMessageChannelService,
|
provide: ResetMessageChannelService,
|
||||||
useValue: {
|
useValue: {
|
||||||
|
|||||||
@ -5,13 +5,17 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AuthException,
|
||||||
|
AuthExceptionCode,
|
||||||
|
} from 'src/engine/core-modules/auth/auth.exception';
|
||||||
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
|
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
|
||||||
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
|
import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/services/create-connected-account.service';
|
||||||
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
|
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
|
||||||
|
import { GoogleAPIScopesService } from 'src/engine/core-modules/auth/services/google-apis-scopes';
|
||||||
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
|
import { ResetCalendarChannelService } from 'src/engine/core-modules/auth/services/reset-calendar-channel.service';
|
||||||
import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service';
|
import { ResetMessageChannelService } from 'src/engine/core-modules/auth/services/reset-message-channel.service';
|
||||||
import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service';
|
import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service';
|
||||||
import { getGoogleApisOauthScopes } from 'src/engine/core-modules/auth/utils/get-google-apis-oauth-scopes';
|
|
||||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||||
@ -59,6 +63,7 @@ export class GoogleAPIsService {
|
|||||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||||
@InjectRepository(ObjectMetadataEntity, 'metadata')
|
@InjectRepository(ObjectMetadataEntity, 'metadata')
|
||||||
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
|
||||||
|
private readonly googleAPIScopesService: GoogleAPIScopesService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async refreshGoogleRefreshToken(input: {
|
async refreshGoogleRefreshToken(input: {
|
||||||
@ -112,7 +117,17 @@ export class GoogleAPIsService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const scopes = getGoogleApisOauthScopes();
|
const { scopes, isValid } =
|
||||||
|
await this.googleAPIScopesService.getScopesFromGoogleAccessTokenAndCheckIfExpectedScopesArePresent(
|
||||||
|
input.accessToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new AuthException(
|
||||||
|
'Unable to connect: Please ensure all permissions are granted',
|
||||||
|
AuthExceptionCode.INSUFFICIENT_SCOPES,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await workspaceDataSource.transaction(
|
await workspaceDataSource.transaction(
|
||||||
async (manager: WorkspaceEntityManager) => {
|
async (manager: WorkspaceEntityManager) => {
|
||||||
|
|||||||
@ -1,3 +1,6 @@
|
|||||||
|
/** email, profile and openid permission can be called without the https://www.googleapis.com/auth/ prefix
|
||||||
|
* see https://developers.google.com/identity/protocols/oauth2/scopes
|
||||||
|
*/
|
||||||
export const getGoogleApisOauthScopes = () => {
|
export const getGoogleApisOauthScopes = () => {
|
||||||
return [
|
return [
|
||||||
'email',
|
'email',
|
||||||
|
|||||||
Reference in New Issue
Block a user