2814 timebox create a poc to test the gmail api (#2868)

* create gmail strategy and controller

* gmail button connect

* wip

* trying to fix error { error: 'invalid_grant', error_description: 'Bad Request' }

* access token working

* refresh token working

* Getting the short term token from the front is working

* working

* rename token

* remove comment

* rename env var

* move file

* Fix

* Fix

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
bosiraphael
2023-12-08 13:13:56 +01:00
committed by GitHub
parent d4613c87f6
commit 7535c84e3d
26 changed files with 660 additions and 89 deletions

View File

@ -47,6 +47,8 @@ import OptionTable from '@site/src/theme/OptionTable'
### Auth
<OptionTable options={[
['MESSAGING_PROVIDER_GMAIL_ENABLED', 'false', 'Enable Gmail API connection'],
['MESSAGING_PROVIDER_GMAIL_CALLBACK_URL', '', 'Gmail auth callback'],
['AUTH_GOOGLE_ENABLED', 'false', 'Enable Goole SSO login'],
['AUTH_GOOGLE_CLIENT_ID', '', 'Google client ID'],
['AUTH_GOOGLE_CLIENT_SECRET', '', 'Google client secret'],

View File

@ -199,6 +199,7 @@ export type Mutation = {
deleteOneObject: ObjectDeleteResponse;
deleteUser: User;
generateApiKeyToken: ApiKeyToken;
generateTransientToken: TransientToken;
impersonate: Verify;
renewToken: AuthTokens;
signUp: LoginToken;
@ -430,6 +431,11 @@ export type Telemetry = {
enabled: Scalars['Boolean'];
};
export type TransientToken = {
__typename?: 'TransientToken';
transientToken: AuthToken;
};
export type UpdateWorkspaceInput = {
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
displayName?: InputMaybe<Scalars['String']>;
@ -644,12 +650,17 @@ export type GenerateApiKeyTokenMutationVariables = Exact<{
export type GenerateApiKeyTokenMutation = { __typename?: 'Mutation', generateApiKeyToken: { __typename?: 'ApiKeyToken', token: string } };
export type GenerateTransientTokenMutationVariables = Exact<{ [key: string]: never; }>;
export type GenerateTransientTokenMutation = { __typename?: 'Mutation', generateTransientToken: { __typename?: 'TransientToken', transientToken: { __typename?: 'AuthToken', token: string } } };
export type ImpersonateMutationVariables = Exact<{
userId: Scalars['String'];
}>;
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type RenewTokenMutationVariables = Exact<{
refreshToken: Scalars['String'];
@ -672,7 +683,7 @@ export type VerifyMutationVariables = Exact<{
}>;
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } };
export type CheckUserExistsQueryVariables = Exact<{
email: Scalars['String'];
@ -702,7 +713,7 @@ export type UploadImageMutationVariables = Exact<{
export type UploadImageMutation = { __typename?: 'Mutation', uploadImage: string };
export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean } };
export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, workspaceMember: { __typename?: 'UserWorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale: string, name: { __typename?: 'UserWorkspaceMemberName', firstName: string, lastName: string } }, defaultWorkspace: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: string, key: string, value: boolean, workspaceId: string }> | null } };
export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>;
@ -788,6 +799,12 @@ export const UserQueryFragmentFragmentDoc = gql`
domainName
inviteHash
allowImpersonation
featureFlags {
id
key
value
workspaceId
}
}
}
`;
@ -895,6 +912,40 @@ export function useGenerateApiKeyTokenMutation(baseOptions?: Apollo.MutationHook
export type GenerateApiKeyTokenMutationHookResult = ReturnType<typeof useGenerateApiKeyTokenMutation>;
export type GenerateApiKeyTokenMutationResult = Apollo.MutationResult<GenerateApiKeyTokenMutation>;
export type GenerateApiKeyTokenMutationOptions = Apollo.BaseMutationOptions<GenerateApiKeyTokenMutation, GenerateApiKeyTokenMutationVariables>;
export const GenerateTransientTokenDocument = gql`
mutation generateTransientToken {
generateTransientToken {
transientToken {
token
}
}
}
`;
export type GenerateTransientTokenMutationFn = Apollo.MutationFunction<GenerateTransientTokenMutation, GenerateTransientTokenMutationVariables>;
/**
* __useGenerateTransientTokenMutation__
*
* To run a mutation, you first call `useGenerateTransientTokenMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useGenerateTransientTokenMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [generateTransientTokenMutation, { data, loading, error }] = useGenerateTransientTokenMutation({
* variables: {
* },
* });
*/
export function useGenerateTransientTokenMutation(baseOptions?: Apollo.MutationHookOptions<GenerateTransientTokenMutation, GenerateTransientTokenMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<GenerateTransientTokenMutation, GenerateTransientTokenMutationVariables>(GenerateTransientTokenDocument, options);
}
export type GenerateTransientTokenMutationHookResult = ReturnType<typeof useGenerateTransientTokenMutation>;
export type GenerateTransientTokenMutationResult = Apollo.MutationResult<GenerateTransientTokenMutation>;
export type GenerateTransientTokenMutationOptions = Apollo.BaseMutationOptions<GenerateTransientTokenMutation, GenerateTransientTokenMutationVariables>;
export const ImpersonateDocument = gql`
mutation Impersonate($userId: String!) {
impersonate(userId: $userId) {

View File

@ -0,0 +1,11 @@
import { gql } from '@apollo/client';
export const GENERATE_ONE_TRANSIENT_TOKEN = gql`
mutation generateTransientToken {
generateTransientToken {
transientToken {
token
}
}
}
`;

View File

@ -1,8 +1,11 @@
import { useCallback } from 'react';
import styled from '@emotion/styled';
import { IconGoogle } from '@/ui/display/icon/components/IconGoogle';
import { Button } from '@/ui/input/button/components/Button';
import { Card } from '@/ui/layout/card/components/Card';
import { REACT_APP_SERVER_AUTH_URL } from '~/config';
import { useGenerateTransientTokenMutation } from '~/generated/graphql';
const StyledCard = styled(Card)`
border-radius: ${({ theme }) => theme.border.radius.md};
@ -27,15 +30,30 @@ const StyledBody = styled.div`
padding: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsAccountsEmptyStateCard = () => (
<StyledCard>
<StyledHeader>No connected account</StyledHeader>
<StyledBody>
<Button
Icon={IconGoogle}
title="Connect with Google"
variant="secondary"
/>
</StyledBody>
</StyledCard>
);
export const SettingsAccountsEmptyStateCard = () => {
const [generateTransientToken] = useGenerateTransientTokenMutation();
const handleGmailLogin = useCallback(async () => {
const authServerUrl = REACT_APP_SERVER_AUTH_URL;
const transientToken = await generateTransientToken();
const token =
transientToken.data?.generateTransientToken.transientToken.token;
window.location.href = `${authServerUrl}/google-gmail?transientToken=${token}`;
}, [generateTransientToken]);
return (
<StyledCard>
<StyledHeader>No connected account</StyledHeader>
<StyledBody>
<Button
Icon={IconGoogle}
title="Connect with Google"
variant="secondary"
onClick={handleGmailLogin}
/>
</StyledBody>
</StyledCard>
);
};

View File

@ -66,7 +66,7 @@ export const settingsFieldMetadataTypes: Partial<
Icon: IconTag,
},
[FieldMetadataType.MultiSelect]: {
label: 'Multi-Select',
label: 'MultiSelect',
Icon: IconTag,
},
[FieldMetadataType.Currency]: {

View File

@ -23,9 +23,9 @@ import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { NavigationDrawer } from '../NavigationDrawer';
import { NavigationDrawerItem } from '../NavigationDrawerItem';
import { NavigationDrawerSectionTitle } from '../NavigationDrawerSectionTitle';
import { NavigationDrawerSection } from '../NavigationDrawerSection';
import { NavigationDrawerItemGroup } from '../NavigationDrawerItemGroup';
import { NavigationDrawerSection } from '../NavigationDrawerSection';
import { NavigationDrawerSectionTitle } from '../NavigationDrawerSectionTitle';
const meta: Meta<typeof NavigationDrawer> = {
title: 'UI/Navigation/NavigationDrawer/NavigationDrawer',

View File

@ -25,6 +25,12 @@ export const USER_QUERY_FRAGMENT = gql`
domainName
inviteHash
allowImpersonation
featureFlags {
id
key
value
workspaceId
}
}
}
`;

View File

@ -5,12 +5,14 @@ import { IconSettings } from '@/ui/display/icon';
import { SubMenuTopBarContainer } from '@/ui/layout/page/SubMenuTopBarContainer';
import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
export const SettingsAccounts = () => (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<Breadcrumb links={[{ children: 'Accounts' }]} />
<SettingsAccountsConnectedAccountsSection />
<SettingsAccountsSettingsSection />
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
export const SettingsAccounts = () => {
return (
<SubMenuTopBarContainer Icon={IconSettings} title="Settings">
<SettingsPageContainer>
<Breadcrumb links={[{ children: 'Accounts' }]} />
<SettingsAccountsConnectedAccountsSection />
<SettingsAccountsSettingsSection />
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};

View File

@ -168,7 +168,7 @@ export const SettingsObjectNewFieldStep2 = () => {
await createMetadataField({
description: validatedFormValues.description,
icon: validatedFormValues.icon,
label: validatedFormValues.label,
label: validatedFormValues.label ?? '',
objectMetadataId: activeObjectMetadataItem.id,
type: validatedFormValues.type,
options:

View File

@ -18,6 +18,11 @@ SIGN_IN_PREFILLED=true
# REFRESH_TOKEN_EXPIRES_IN=90d
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
# AUTH_GOOGLE_ENABLED=false
# MESSAGING_PROVIDER_GMAIL_ENABLED=false
# AUTH_GOOGLE_CLIENT_ID=replace_me_with_google_client_id
# AUTH_GOOGLE_CLIENT_SECRET=replace_me_with_google_client_secret
# AUTH_GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/redirect
# MESSAGING_PROVIDER_GMAIL_CALLBACK_URL=http://localhost:3000/auth/google-gmail/get-access-token
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=.local-storage
# SUPPORT_DRIVER=front

View File

@ -19,5 +19,6 @@ REFRESH_TOKEN_SECRET=secret_refresh_token
# REFRESH_TOKEN_EXPIRES_IN=90d
# FRONT_AUTH_CALLBACK_URL=http://localhost:3001/verify
# AUTH_GOOGLE_ENABLED=false
# MESSAGING_PROVIDER_GMAIL_ENABLED=false
# STORAGE_TYPE=local
# STORAGE_LOCAL_PATH=.local-storage

View File

@ -35,6 +35,7 @@
"@apollo/server": "^4.7.3",
"@aws-sdk/client-s3": "^3.363.0",
"@aws-sdk/credential-providers": "^3.363.0",
"@google-cloud/local-auth": "2.1.0",
"@graphql-tools/schema": "^10.0.0",
"@graphql-yoga/nestjs": "2.1.0",
"@nestjs/apollo": "^11.0.5",
@ -68,6 +69,7 @@
"dataloader": "^2.2.2",
"date-fns": "^2.30.0",
"file-type": "16.5.4",
"googleapis": "105",
"graphql": "16.8.0",
"graphql-fields": "^2.0.3",
"graphql-subscriptions": "2.0.0",

View File

@ -12,15 +12,16 @@ import { DataSourceModule } from 'src/metadata/data-source/data-source.module';
import { UserModule } from 'src/core/user/user.module';
import { WorkspaceManagerModule } from 'src/workspace/workspace-manager/workspace-manager.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { GoogleAuthController } from 'src/core/auth/controllers/google-auth.controller';
import { GoogleGmailAuthController } from 'src/core/auth/controllers/google-gmail-auth.controller';
import { VerifyAuthController } from 'src/core/auth/controllers/verify-auth.controller';
import { TokenService } from 'src/core/auth/services/token.service';
import { GoogleGmailService } from 'src/core/auth/services/google-gmail.service';
import { AuthResolver } from './auth.resolver';
import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
import { AuthService } from './services/auth.service';
import { GoogleAuthController } from './controllers/google-auth.controller';
import { VerifyAuthController } from './controllers/verify-auth.controller';
import { TokenService } from './services/token.service';
const jwtModule = JwtModule.registerAsync({
useFactory: async (environmentService: EnvironmentService) => {
return {
@ -43,8 +44,18 @@ const jwtModule = JwtModule.registerAsync({
TypeORMModule,
TypeOrmModule.forFeature([Workspace, User, RefreshToken], 'core'),
],
controllers: [GoogleAuthController, VerifyAuthController],
providers: [AuthService, TokenService, JwtAuthStrategy, AuthResolver],
controllers: [
GoogleAuthController,
GoogleGmailAuthController,
VerifyAuthController,
],
providers: [
AuthService,
TokenService,
JwtAuthStrategy,
AuthResolver,
GoogleGmailService,
],
exports: [jwtModule, TokenService],
})
export class AuthModule {}

View File

@ -15,6 +15,8 @@ import { Workspace } from 'src/core/workspace/workspace.entity';
import { AuthWorkspace } from 'src/decorators/auth-workspace.decorator';
import { User } from 'src/core/user/user.entity';
import { ApiKeyTokenInput } from 'src/core/auth/dto/api-key-token.input';
import { TransientToken } from 'src/core/auth/dto/transient-token.entity';
import { UserService } from 'src/core/user/services/user.service';
import { ApiKeyToken, AuthTokens } from './dto/token.entity';
import { TokenService } from './services/token.service';
@ -38,6 +40,7 @@ export class AuthResolver {
private readonly workspaceRepository: Repository<Workspace>,
private authService: AuthService,
private tokenService: TokenService,
private userService: UserService,
) {}
@Query(() => UserExists)
@ -85,6 +88,20 @@ export class AuthResolver {
return { loginToken };
}
@Mutation(() => TransientToken)
@UseGuards(JwtAuthGuard)
async generateTransientToken(
@AuthUser() user: User,
): Promise<TransientToken | void> {
const workspaceMember = await this.userService.loadWorkspaceMember(user);
const transientToken = await this.tokenService.generateTransientToken(
workspaceMember.id,
user.defaultWorkspace.id,
);
return { transientToken };
}
@Mutation(() => Verify)
async verify(@Args() verifyInput: VerifyInput): Promise<Verify> {
const email = await this.tokenService.verifyLoginToken(

View File

@ -0,0 +1,48 @@
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { Response } from 'express';
import { GoogleGmailProviderEnabledGuard } from 'src/core/auth/guards/google-gmail-provider-enabled.guard';
import { GoogleGmailOauthGuard } from 'src/core/auth/guards/google-gmail-oauth.guard';
import { GoogleGmailRequest } from 'src/core/auth/strategies/google-gmail.auth.strategy';
import { GoogleGmailService } from 'src/core/auth/services/google-gmail.service';
import { TokenService } from 'src/core/auth/services/token.service';
@Controller('auth/google-gmail')
export class GoogleGmailAuthController {
constructor(
private readonly googleGmailService: GoogleGmailService,
private readonly tokenService: TokenService,
) {}
@Get()
@UseGuards(GoogleGmailProviderEnabledGuard, GoogleGmailOauthGuard)
async googleAuth() {
// As this method is protected by Google Auth guard, it will trigger Google SSO flow
return;
}
@Get('get-access-token')
@UseGuards(GoogleGmailProviderEnabledGuard, GoogleGmailOauthGuard)
async googleAuthGetAccessToken(
@Req() req: GoogleGmailRequest,
@Res() res: Response,
) {
const { user: gmailUser } = req;
const { accessToken, refreshToken, transientToken } = gmailUser;
const { workspaceMemberId, workspaceId } =
await this.tokenService.verifyTransientToken(transientToken);
this.googleGmailService.saveConnectedAccount({
workspaceMemberId: workspaceMemberId,
workspaceId: workspaceId,
type: 'gmail',
accessToken,
refreshToken,
});
return res.redirect('http://localhost:3001');
}
}

View File

@ -0,0 +1,31 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class SaveConnectedAccountInput {
@Field(() => String)
@IsNotEmpty()
@IsString()
workspaceMemberId: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
workspaceId: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
type: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
accessToken: string;
@Field(() => String)
@IsNotEmpty()
@IsString()
refreshToken: string;
}

View File

@ -0,0 +1,9 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { AuthToken } from './token.entity';
@ObjectType()
export class TransientToken {
@Field(() => AuthToken)
transientToken: AuthToken;
}

View File

@ -0,0 +1,27 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GoogleGmailOauthGuard extends AuthGuard('google-gmail') {
constructor() {
super({
prompt: 'select_account',
});
}
async canActivate(context: ExecutionContext) {
try {
const request = context.switchToHttp().getRequest();
const transientToken = request.query.transientToken;
if (transientToken && typeof transientToken === 'string') {
request.params.transientToken = transientToken;
}
const activate = (await super.canActivate(context)) as boolean;
return activate;
} catch (ex) {
throw ex;
}
}
}

View File

@ -0,0 +1,21 @@
import { Injectable, CanActivate, NotFoundException } from '@nestjs/common';
import { Observable } from 'rxjs';
import { GoogleGmailStrategy } from 'src/core/auth/strategies/google-gmail.auth.strategy';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@Injectable()
export class GoogleGmailProviderEnabledGuard implements CanActivate {
constructor(private readonly environmentService: EnvironmentService) {}
canActivate(): boolean | Promise<boolean> | Observable<boolean> {
if (!this.environmentService.isMessagingProviderGmailEnabled()) {
throw new NotFoundException('Gmail auth is not enabled');
}
new GoogleGmailStrategy(this.environmentService);
return true;
}
}

View File

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { DataSourceService } from 'src/metadata/data-source/data-source.service';
import { TypeORMService } from 'src/database/typeorm/typeorm.service';
import { SaveConnectedAccountInput } from 'src/core/auth/dto/save-connected-account';
@Injectable()
export class GoogleGmailService {
constructor(
private readonly dataSourceService: DataSourceService,
private readonly typeORMService: TypeORMService,
) {}
async saveConnectedAccount(
saveConnectedAccountInput: SaveConnectedAccountInput,
) {
const { workspaceId, type, accessToken, refreshToken } =
saveConnectedAccountInput;
const dataSourceMetadata =
await this.dataSourceService.getLastDataSourceMetadataFromWorkspaceIdOrFail(
workspaceId,
);
const workspaceDataSource = await this.typeORMService.connectToDataSource(
dataSourceMetadata,
);
await workspaceDataSource?.query(
`INSERT INTO ${dataSourceMetadata.schema}."connectedAccount" ("type", "accessToken", "refreshToken") VALUES ('${type}', '${accessToken}', '${refreshToken}')`,
);
return;
}
}

View File

@ -114,6 +114,29 @@ export class TokenService {
};
}
async generateTransientToken(
workspaceMemberId: string,
workspaceId: string,
): Promise<AuthToken> {
const secret = this.environmentService.getLoginTokenSecret();
const expiresIn = this.environmentService.getTransientTokenExpiresIn();
assert(expiresIn, '', InternalServerErrorException);
const expiresAt = addMilliseconds(new Date().getTime(), ms(expiresIn));
const jwtPayload = {
sub: workspaceMemberId,
workspaceId,
};
return {
token: this.jwtService.sign(jwtPayload, {
secret,
expiresIn,
}),
expiresAt,
};
}
async generateApiKeyToken(
workspaceId: string,
apiKeyId?: string,
@ -166,6 +189,20 @@ export class TokenService {
return payload.sub;
}
async verifyTransientToken(transientToken: string): Promise<{
workspaceMemberId: string;
workspaceId: string;
}> {
const transientTokenSecret = this.environmentService.getLoginTokenSecret();
const payload = await this.verifyJwt(transientToken, transientTokenSecret);
return {
workspaceMemberId: payload.sub,
workspaceId: payload.workspaceId,
};
}
async verifyRefreshToken(refreshToken: string) {
const secret = this.environmentService.getRefreshTokenSecret();
const coolDown = this.environmentService.getRefreshTokenCoolDown();

View File

@ -0,0 +1,80 @@
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
import { Request } from 'express';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
export type GoogleGmailRequest = Request & {
user: {
firstName?: string | null;
lastName?: string | null;
email: string;
picture: string | null;
workspaceInviteHash?: string;
accessToken: string;
refreshToken: string;
transientToken: string;
};
};
@Injectable()
export class GoogleGmailStrategy extends PassportStrategy(
Strategy,
'google-gmail',
) {
constructor(environmentService: EnvironmentService) {
super({
clientID: environmentService.getAuthGoogleClientId(),
clientSecret: environmentService.getAuthGoogleClientSecret(),
callbackURL: environmentService.getMessagingProviderGmailCallbackUrl(),
scope: [
'email',
'profile',
'https://www.googleapis.com/auth/gmail.readonly',
],
passReqToCallback: true,
});
}
authenticate(req: any, options: any) {
options = {
...options,
accessType: 'offline',
prompt: 'consent',
state: JSON.stringify({
transientToken: req.params.transientToken,
}),
};
return super.authenticate(req, options);
}
async validate(
request: GoogleGmailRequest,
accessToken: string,
refreshToken: string,
profile: any,
done: VerifyCallback,
): Promise<void> {
const { name, emails, photos } = profile;
const state =
typeof request.query.state === 'string'
? JSON.parse(request.query.state)
: undefined;
const user: GoogleGmailRequest['user'] = {
email: emails[0].value,
firstName: name.givenName,
lastName: name.familyName,
picture: photos?.[0]?.value,
accessToken,
refreshToken,
transientToken: state.transientToken,
};
done(null, user);
}
}

View File

@ -69,8 +69,9 @@ export class JwtAuthStrategy extends PassportStrategy(Strategy, 'jwt') {
let user;
if (payload.workspaceId) {
user = await this.userRepository.findOneBy({
id: payload.sub,
user = await this.userRepository.findOne({
where: { id: payload.sub },
relations: ['defaultWorkspace'],
});
if (!user) {
throw new UnauthorizedException();

View File

@ -84,6 +84,12 @@ export class EnvironmentService {
return this.configService.get<string>('LOGIN_TOKEN_EXPIRES_IN') ?? '15m';
}
getTransientTokenExpiresIn(): string {
return (
this.configService.get<string>('SHORT_TERM_TOKEN_EXPIRES_IN') ?? '5m'
);
}
getApiTokenExpiresIn(): string {
return this.configService.get<string>('API_TOKEN_EXPIRES_IN') ?? '1000y';
}
@ -95,6 +101,19 @@ export class EnvironmentService {
);
}
isMessagingProviderGmailEnabled(): boolean {
return (
this.configService.get<boolean>('MESSAGING_PROVIDER_GMAIL_ENABLED') ??
false
);
}
getMessagingProviderGmailCallbackUrl(): string | undefined {
return this.configService.get<string>(
'MESSAGING_PROVIDER_GMAIL_CALLBACK_URL',
);
}
isAuthGoogleEnabled(): boolean {
return this.configService.get<boolean>('AUTH_GOOGLE_ENABLED') ?? false;
}

View File

@ -26,60 +26,60 @@ const connectedAccountMetadata = {
defaultValue: { value: '' },
},
{
isCustom: false,
isActive: true,
type: FieldMetadataType.TEXT,
name: 'accessToken',
label: 'accessToken',
targetColumnMap: {
value: 'accessToken',
},
description: 'Messaging provider access token',
icon: 'IconKey',
isNullable: false,
defaultValue: { value: '' },
isCustom: false,
isActive: true,
type: FieldMetadataType.TEXT,
name: 'accessToken',
label: 'accessToken',
targetColumnMap: {
value: 'accessToken',
},
description: 'Messaging provider access token',
icon: 'IconKey',
isNullable: false,
defaultValue: { value: '' },
},
{
isCustom: false,
isActive: true,
type: FieldMetadataType.TEXT,
name: 'refreshToken',
label: 'refreshToken',
targetColumnMap: {
value: 'refreshToken',
},
description: 'Messaging provider refresh token',
icon: 'IconKey',
isNullable: false,
defaultValue: { value: '' },
isCustom: false,
isActive: true,
type: FieldMetadataType.TEXT,
name: 'refreshToken',
label: 'refreshToken',
targetColumnMap: {
value: 'refreshToken',
},
description: 'Messaging provider refresh token',
icon: 'IconKey',
isNullable: false,
defaultValue: { value: '' },
},
{
isCustom: false,
isActive: true,
type: FieldMetadataType.TEXT, // Should be an array of strings
name: 'externalScopes',
label: 'externalScopes',
targetColumnMap: {
value: 'externalScopes',
},
description: 'External scopes',
icon: 'IconCircle',
isNullable: false,
defaultValue: { value: '' },
isCustom: false,
isActive: true,
type: FieldMetadataType.TEXT, // Should be an array of strings
name: 'externalScopes',
label: 'externalScopes',
targetColumnMap: {
value: 'externalScopes',
},
description: 'External scopes',
icon: 'IconCircle',
isNullable: false,
defaultValue: { value: '' },
},
{
isCustom: false,
isActive: true,
type: FieldMetadataType.BOOLEAN, // Should be an array of strings
name: 'hasEmailScope',
label: 'hasEmailScope',
targetColumnMap: {
value: 'hasEmailScope',
},
description: 'Has email scope',
icon: 'IconMail',
isNullable: false,
defaultValue: { value: '' },
isCustom: false,
isActive: true,
type: FieldMetadataType.BOOLEAN, // Should be an array of strings
name: 'hasEmailScope',
label: 'hasEmailScope',
targetColumnMap: {
value: 'hasEmailScope',
},
description: 'Has email scope',
icon: 'IconMail',
isNullable: false,
defaultValue: { value: '' },
},
],
};

View File

@ -1321,6 +1321,16 @@
dependencies:
lodash "^4.17.21"
"@google-cloud/local-auth@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@google-cloud/local-auth/-/local-auth-2.1.0.tgz#5cb441e5016ce95996b14522e06fb4d9896a6ed0"
integrity sha512-ymZ1XuyKcRcro0aiMYz3hVGbZ+QZmN5V1Eyjvw2k1xqq76PwmDer0DIPxdxkLzfW9Inr8+g+MS9t9fZ7dOlTOQ==
dependencies:
arrify "^2.0.1"
google-auth-library "^8.0.2"
open "^7.0.3"
server-destroy "^1.0.1"
"@graphql-tools/executor@^1.0.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@graphql-tools/executor/-/executor-1.2.0.tgz#6c45f4add765769d9820c4c4405b76957ba39c79"
@ -3947,6 +3957,11 @@ arraybuffer.prototype.slice@^1.0.2:
is-array-buffer "^3.0.2"
is-shared-array-buffer "^1.0.2"
arrify@^2.0.0, arrify@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
asap@^2.0.0:
version "2.0.6"
resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
@ -4058,7 +4073,7 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-js@^1.3.1:
base64-js@^1.3.0, base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
@ -4076,6 +4091,11 @@ bcrypt@^5.1.1:
"@mapbox/node-pre-gyp" "^1.0.11"
node-addon-api "^5.0.0"
bignumber.js@^9.0.0:
version "9.1.2"
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.2.tgz#b7c4242259c008903b13707983b5f4bbd31eda0c"
integrity sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==
binary-extensions@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
@ -4886,7 +4906,7 @@ dset@^3.1.1, dset@^3.1.2:
resolved "https://registry.yarnpkg.com/dset/-/dset-3.1.3.tgz#c194147f159841148e8e34ca41f638556d9542d2"
integrity sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==
ecdsa-sig-formatter@1.0.11:
ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
@ -5336,6 +5356,11 @@ express@4.18.2, express@^4.17.1:
utils-merge "1.0.1"
vary "~1.1.2"
extend@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
external-editor@^3.0.3:
version "3.1.0"
resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
@ -5409,6 +5434,11 @@ fast-safe-stringify@2.1.1, fast-safe-stringify@^2.1.1:
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
fast-text-encoding@^1.0.0:
version "1.0.6"
resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867"
integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==
fast-url-parser@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d"
@ -5667,6 +5697,23 @@ gauge@^3.0.0:
strip-ansi "^6.0.1"
wide-align "^1.1.2"
gaxios@^5.0.0, gaxios@^5.0.1:
version "5.1.3"
resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-5.1.3.tgz#f7fa92da0fe197c846441e5ead2573d4979e9013"
integrity sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==
dependencies:
extend "^3.0.2"
https-proxy-agent "^5.0.0"
is-stream "^2.0.0"
node-fetch "^2.6.9"
gcp-metadata@^5.3.0:
version "5.3.0"
resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-5.3.0.tgz#6f45eb473d0cb47d15001476b48b663744d25408"
integrity sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==
dependencies:
gaxios "^5.0.0"
json-bigint "^1.0.0"
gauge@^4.0.3:
version "4.0.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-4.0.4.tgz#52ff0652f2bbf607a989793d53b751bef2328dce"
@ -5814,6 +5861,48 @@ globby@^11.1.0:
merge2 "^1.4.1"
slash "^3.0.0"
google-auth-library@^8.0.2:
version "8.9.0"
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-8.9.0.tgz#15a271eb2ec35d43b81deb72211bd61b1ef14dd0"
integrity sha512-f7aQCJODJFmYWN6PeNKzgvy9LI2tYmXnzpNDHEjG5sDNPgGb2FXQyTBnXeSH+PAtpKESFD+LmHw3Ox3mN7e1Fg==
dependencies:
arrify "^2.0.0"
base64-js "^1.3.0"
ecdsa-sig-formatter "^1.0.11"
fast-text-encoding "^1.0.0"
gaxios "^5.0.0"
gcp-metadata "^5.3.0"
gtoken "^6.1.0"
jws "^4.0.0"
lru-cache "^6.0.0"
google-p12-pem@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-4.0.1.tgz#82841798253c65b7dc2a4e5fe9df141db670172a"
integrity sha512-WPkN4yGtz05WZ5EhtlxNDWPhC4JIic6G8ePitwUWy4l+XPVYec+a0j0Ts47PDtW59y3RwAhUd9/h9ZZ63px6RQ==
dependencies:
node-forge "^1.3.1"
googleapis-common@^6.0.0:
version "6.0.4"
resolved "https://registry.yarnpkg.com/googleapis-common/-/googleapis-common-6.0.4.tgz#bd968bef2a478bcd3db51b27655502a11eaf8bf4"
integrity sha512-m4ErxGE8unR1z0VajT6AYk3s6a9gIMM6EkDZfkPnES8joeOlEtFEJeF8IyZkb0tjPXkktUfYrE4b3Li1DNyOwA==
dependencies:
extend "^3.0.2"
gaxios "^5.0.1"
google-auth-library "^8.0.2"
qs "^6.7.0"
url-template "^2.0.8"
uuid "^9.0.0"
googleapis@105:
version "105.0.0"
resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-105.0.0.tgz#65ff8969cf837a08ee127ae56468ac3d96ddc9d5"
integrity sha512-wH/jU/6QpqwsjTKj4vfKZz97ne7xT7BBbKwzQEwnbsG8iH9Seyw19P+AuLJcxNNrmgblwLqfr3LORg4Okat1BQ==
dependencies:
google-auth-library "^8.0.2"
googleapis-common "^6.0.0"
gopd@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
@ -5892,6 +5981,15 @@ graphql@*, "graphql@0.13.1 - 16", graphql@16.8.0:
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.0.tgz#374478b7f27b2dc6153c8f42c1b80157f79d79d4"
integrity sha512-0oKGaR+y3qcS5mCu1vb7KG+a89vjn06C7Ihq/dDl3jA+A8B3TKomvi3CiEcVLJQGalbu8F52LxkOym7U5sSfbg==
gtoken@^6.1.0:
version "6.1.2"
resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-6.1.2.tgz#aeb7bdb019ff4c3ba3ac100bbe7b6e74dce0e8bc"
integrity sha512-4ccGpzz7YAr7lxrT2neugmXQ3hP9ho2gcaityLVkiUecAiwiy60Ii8gRbZeOsXV19fYaRjgBSshs8kXw+NKCPQ==
dependencies:
gaxios "^5.0.1"
google-p12-pem "^4.0.0"
jws "^4.0.0"
has-bigints@^1.0.1, has-bigints@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.2.tgz#0871bd3e3d51626f6ca0966668ba35d5602d6eaa"
@ -6839,6 +6937,13 @@ jsesc@^2.5.1:
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
json-bigint@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1"
integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==
dependencies:
bignumber.js "^9.0.0"
json-buffer@3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
@ -6930,6 +7035,15 @@ jwa@^1.4.1:
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jwa@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc"
integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
@ -6938,6 +7052,14 @@ jws@^3.2.2:
jwa "^1.4.1"
safe-buffer "^5.0.1"
jws@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4"
integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==
dependencies:
jwa "^2.0.0"
safe-buffer "^5.0.1"
keyv@^4.5.3:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@ -7577,13 +7699,18 @@ node-emoji@1.11.0:
dependencies:
lodash "^4.17.21"
node-fetch@^2.6.1, node-fetch@^2.6.7:
node-fetch@^2.6.1, node-fetch@^2.6.7, node-fetch@^2.6.9:
version "2.7.0"
resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
dependencies:
whatwg-url "^5.0.0"
node-forge@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3"
integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==
node-gyp-build-optional-packages@5.0.7:
version "5.0.7"
resolved "https://registry.yarnpkg.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.0.7.tgz#5d2632bbde0ab2f6e22f1bbac2199b07244ae0b3"
@ -7746,7 +7873,7 @@ onetime@^5.1.0, onetime@^5.1.2:
dependencies:
mimic-fn "^2.1.0"
open@^7.4.2:
open@^7.0.3, open@^7.4.2:
version "7.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321"
integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==
@ -8251,7 +8378,7 @@ qs@6.11.0:
dependencies:
side-channel "^1.0.4"
qs@^6.11.0:
qs@^6.11.0, qs@^6.7.0:
version "6.11.2"
resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.2.tgz#64bea51f12c1f5da1bc01496f48ffcff7c69d7d9"
integrity sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==
@ -8591,6 +8718,11 @@ serve-static@1.15.0:
parseurl "~1.3.3"
send "0.18.0"
server-destroy@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd"
integrity sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==
set-blocking@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
@ -9473,6 +9605,11 @@ uri-js@^4.2.2:
dependencies:
punycode "^2.1.0"
url-template@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/url-template/-/url-template-2.0.8.tgz#fc565a3cccbff7730c775f5641f9555791439f21"
integrity sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==
urlpattern-polyfill@^9.0.0:
version "9.0.0"
resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-9.0.0.tgz#bc7e386bb12fd7898b58d1509df21d3c29ab3460"