Compare commits

..

9 Commits

Author SHA1 Message Date
mirarifhasan
6ec4714afa chore: modify mailer module 2024-05-28 16:54:33 +06:00
mirarifhasan
da2ec5a46d fix: feedback resolved 2024-05-27 19:19:27 +06:00
mirarifhasan
ab87a7a16b chore: restrict on update directly instead of dedicated mutation 2024-05-27 13:04:20 +06:00
mirarifhasan
8b14f77d78 feat: email auth provider disabled on smtp disable 2024-05-27 13:02:01 +06:00
mirarifhasan
bcaed7ec69 feat: added query to see is smtp enabled or not 2024-05-23 15:10:46 +06:00
mirarifhasan
c371b56a23 test: fix test cases 2024-05-21 20:27:51 +06:00
mirarifhasan
5f52acacc0 feat: added advance mailer configurations from infra config 2024-05-21 20:17:46 +06:00
mirarifhasan
742eca6d10 feat: event emitter added 2024-05-20 23:20:27 +06:00
mirarifhasan
1e860c3535 feat: env variable added in infra-config for smtp enable status 2024-05-19 23:39:20 +06:00
26 changed files with 16479 additions and 18390 deletions

View File

@@ -35,9 +35,20 @@ MICROSOFT_SCOPE="user.read"
MICROSOFT_TENANT="common" MICROSOFT_TENANT="common"
# Mailer config # Mailer config
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com" MAILER_SMTP_ENABLE="true"
MAILER_USE_ADVANCE_CONFIGS="false"
MAILER_ADDRESS_FROM='"From Name Here" <from@example.com>' MAILER_ADDRESS_FROM='"From Name Here" <from@example.com>'
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.com" # used if custom mailer configs is false
# The following are used if custom mailer configs is true
MAILER_SMTP_HOST="smtp.domain.com"
MAILER_SMTP_PORT="587"
MAILER_SMTP_SECURE="true"
MAILER_SMTP_USER="user@domain.com"
MAILER_SMTP_PASSWORD="pass"
MAILER_TLS_REJECT_UNAUTHORIZED="true"
# Rate Limit Config # Rate Limit Config
RATE_LIMIT_TTL=60 # In seconds RATE_LIMIT_TTL=60 # In seconds
RATE_LIMIT_MAX=100 # Max requests per IP RATE_LIMIT_MAX=100 # Max requests per IP

View File

@@ -30,6 +30,7 @@
"@nestjs/common": "10.2.7", "@nestjs/common": "10.2.7",
"@nestjs/config": "3.1.1", "@nestjs/config": "3.1.1",
"@nestjs/core": "10.2.7", "@nestjs/core": "10.2.7",
"@nestjs/event-emitter": "2.0.4",
"@nestjs/graphql": "12.0.9", "@nestjs/graphql": "12.0.9",
"@nestjs/jwt": "10.1.1", "@nestjs/jwt": "10.1.1",
"@nestjs/passport": "10.0.2", "@nestjs/passport": "10.0.2",

View File

@@ -22,6 +22,7 @@ import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args'; import { OffsetPaginationArgs } from 'src/types/input-types.args';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { EventEmitter2 } from '@nestjs/event-emitter';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>(); const mockPubSub = mockDeep<PubSubService>();
@@ -34,6 +35,7 @@ const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockMailerService = mockDeep<MailerService>(); const mockMailerService = mockDeep<MailerService>();
const mockShortcodeService = mockDeep<ShortcodeService>(); const mockShortcodeService = mockDeep<ShortcodeService>();
const mockConfigService = mockDeep<ConfigService>(); const mockConfigService = mockDeep<ConfigService>();
const mockEventEmitter = mockDeep<EventEmitter2>();
const adminService = new AdminService( const adminService = new AdminService(
mockUserService, mockUserService,
@@ -44,9 +46,9 @@ const adminService = new AdminService(
mockTeamInvitationService, mockTeamInvitationService,
mockPubSub as any, mockPubSub as any,
mockPrisma as any, mockPrisma as any,
mockMailerService,
mockShortcodeService, mockShortcodeService,
mockConfigService, mockConfigService,
mockEventEmitter,
); );
const invitedUsers: InvitedUsers[] = [ const invitedUsers: InvitedUsers[] = [
@@ -121,7 +123,6 @@ describe('AdminService', () => {
NOT: { NOT: {
inviteeEmail: { inviteeEmail: {
in: [dbAdminUsers[0].email], in: [dbAdminUsers[0].email],
mode: 'insensitive',
}, },
}, },
}, },
@@ -230,10 +231,7 @@ describe('AdminService', () => {
expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({ expect(mockPrisma.invitedUsers.deleteMany).toHaveBeenCalledWith({
where: { where: {
inviteeEmail: { inviteeEmail: { in: [invitedUsers[0].inviteeEmail] },
in: [invitedUsers[0].inviteeEmail],
mode: 'insensitive',
},
}, },
}); });
expect(result).toEqualRight(true); expect(result).toEqualRight(true);

View File

@@ -19,7 +19,6 @@ import {
USER_IS_ADMIN, USER_IS_ADMIN,
USER_NOT_FOUND, USER_NOT_FOUND,
} from '../errors'; } from '../errors';
import { MailerService } from '../mailer/mailer.service';
import { InvitedUser } from './invited-user.model'; import { InvitedUser } from './invited-user.model';
import { TeamService } from '../team/team.service'; import { TeamService } from '../team/team.service';
import { TeamCollectionService } from '../team-collection/team-collection.service'; import { TeamCollectionService } from '../team-collection/team-collection.service';
@@ -31,6 +30,8 @@ import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args'; import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { UserDeletionResult } from 'src/user/user.model'; import { UserDeletionResult } from 'src/user/user.model';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Events } from 'src/types/EventEmitter';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
@@ -43,9 +44,9 @@ export class AdminService {
private readonly teamInvitationService: TeamInvitationService, private readonly teamInvitationService: TeamInvitationService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly mailerService: MailerService,
private readonly shortcodeService: ShortcodeService, private readonly shortcodeService: ShortcodeService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
) {} ) {}
/** /**
@@ -89,32 +90,26 @@ export class AdminService {
adminEmail: string, adminEmail: string,
inviteeEmail: string, inviteeEmail: string,
) { ) {
if (inviteeEmail.toLowerCase() == adminEmail.toLowerCase()) { if (inviteeEmail == adminEmail) return E.left(DUPLICATE_EMAIL);
return E.left(DUPLICATE_EMAIL);
}
if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL); if (!validateEmail(inviteeEmail)) return E.left(INVALID_EMAIL);
const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({ const alreadyInvitedUser = await this.prisma.invitedUsers.findFirst({
where: { where: {
inviteeEmail: { inviteeEmail: inviteeEmail,
equals: inviteeEmail,
mode: 'insensitive',
},
}, },
}); });
if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED); if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED);
try { this.eventEmitter.emit(Events.MAILER_SEND_USER_INVITATION_EMAIL, {
await this.mailerService.sendUserInvitationEmail(inviteeEmail, { to: inviteeEmail,
mailDesc: {
template: 'user-invitation', template: 'user-invitation',
variables: { variables: {
inviteeEmail: inviteeEmail, inviteeEmail: inviteeEmail,
magicLink: `${this.configService.get('VITE_BASE_URL')}`, magicLink: `${this.configService.get('VITE_BASE_URL')}`,
}, },
}); },
} catch (e) { });
return E.left(EMAIL_FAILED);
}
// Add invitee email to the list of invited users by admin // Add invitee email to the list of invited users by admin
const dbInvitedUser = await this.prisma.invitedUsers.create({ const dbInvitedUser = await this.prisma.invitedUsers.create({
@@ -164,7 +159,7 @@ export class AdminService {
try { try {
await this.prisma.invitedUsers.deleteMany({ await this.prisma.invitedUsers.deleteMany({
where: { where: {
inviteeEmail: { in: inviteeEmails, mode: 'insensitive' }, inviteeEmail: { in: inviteeEmails },
}, },
}); });
return E.right(true); return E.right(true);
@@ -194,7 +189,6 @@ export class AdminService {
NOT: { NOT: {
inviteeEmail: { inviteeEmail: {
in: userEmailObjs.map((user) => user.email), in: userEmailObjs.map((user) => user.email),
mode: 'insensitive',
}, },
}, },
}, },

View File

@@ -292,6 +292,14 @@ export class InfraResolver {
return this.infraConfigService.getAllowedAuthProviders(); return this.infraConfigService.getAllowedAuthProviders();
} }
@Query(() => Boolean, {
description: 'Check if the SMTP is enabled or not',
})
@UseGuards(GqlAuthGuard)
isSMTPEnabled() {
return this.infraConfigService.isSMTPEnabled();
}
/* Mutations */ /* Mutations */
@Mutation(() => [InfraConfig], { @Mutation(() => [InfraConfig], {
@@ -359,4 +367,23 @@ export class InfraResolver {
return true; return true;
} }
@Mutation(() => Boolean, {
description: 'Enable or Disable SMTP for sending emails',
})
@UseGuards(GqlAuthGuard, GqlAdminGuard)
async toggleSMTP(
@Args({
name: 'status',
type: () => ServiceStatus,
description: 'Toggle SMTP',
})
status: ServiceStatus,
) {
const isUpdated = await this.infraConfigService.enableAndDisableSMTP(
status,
);
if (E.isLeft(isUpdated)) throwErr(isUpdated.left);
return true;
}
} }

View File

@@ -27,9 +27,11 @@ import { MailerModule } from './mailer/mailer.module';
import { PosthogModule } from './posthog/posthog.module'; import { PosthogModule } from './posthog/posthog.module';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({ @Module({
imports: [ imports: [
EventEmitterModule.forRoot(),
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [async () => loadInfraConfiguration()], load: [async () => loadInfraConfiguration()],

View File

@@ -23,6 +23,7 @@ import * as argon2 from 'argon2';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service'; import { InfraConfigService } from 'src/infra-config/infra-config.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockUser = mockDeep<UserService>(); const mockUser = mockDeep<UserService>();
@@ -30,6 +31,7 @@ const mockJWT = mockDeep<JwtService>();
const mockMailer = mockDeep<MailerService>(); const mockMailer = mockDeep<MailerService>();
const mockConfigService = mockDeep<ConfigService>(); const mockConfigService = mockDeep<ConfigService>();
const mockInfraConfigService = mockDeep<InfraConfigService>(); const mockInfraConfigService = mockDeep<InfraConfigService>();
const mockEventEmitter = mockDeep<EventEmitter2>();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
@@ -37,9 +39,9 @@ const authService = new AuthService(
mockUser, mockUser,
mockPrisma, mockPrisma,
mockJWT, mockJWT,
mockMailer,
mockConfigService, mockConfigService,
mockInfraConfigService, mockInfraConfigService,
mockEventEmitter,
); );
const currentTime = new Date(); const currentTime = new Date();

View File

@@ -1,5 +1,4 @@
import { HttpStatus, Injectable } from '@nestjs/common'; import { HttpStatus, Injectable } from '@nestjs/common';
import { MailerService } from 'src/mailer/mailer.service';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { VerifyMagicDto } from './dto/verify-magic.dto'; import { VerifyMagicDto } from './dto/verify-magic.dto';
@@ -30,6 +29,8 @@ import { VerificationToken } from '@prisma/client';
import { Origin } from './helper'; import { Origin } from './helper';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { InfraConfigService } from 'src/infra-config/infra-config.service'; import { InfraConfigService } from 'src/infra-config/infra-config.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Events } from 'src/types/EventEmitter';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -37,9 +38,9 @@ export class AuthService {
private usersService: UserService, private usersService: UserService,
private prismaService: PrismaService, private prismaService: PrismaService,
private jwtService: JwtService, private jwtService: JwtService,
private readonly mailerService: MailerService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private infraConfigService: InfraConfigService, private infraConfigService: InfraConfigService,
private eventEmitter: EventEmitter2,
) {} ) {}
/** /**
@@ -234,11 +235,14 @@ export class AuthService {
url = this.configService.get('VITE_BASE_URL'); url = this.configService.get('VITE_BASE_URL');
} }
await this.mailerService.sendEmail(email, { this.eventEmitter.emit(Events.MAILER_SEND_EMAIL, {
template: 'user-invitation', to: email,
variables: { mailDesc: {
inviteeEmail: email, template: 'user-invitation',
magicLink: `${url}/enter?token=${generatedTokens.token}`, variables: {
inviteeEmail: email,
magicLink: `${url}/enter?token=${generatedTokens.token}`,
},
}, },
}); });

View File

@@ -678,6 +678,19 @@ export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
export const MAILER_FROM_ADDRESS_UNDEFINED = export const MAILER_FROM_ADDRESS_UNDEFINED =
'mailer/from_address_undefined' as const; 'mailer/from_address_undefined' as const;
/**
* MAILER_SMTP_USER environment variable is not defined
* (MailerModule)
*/
export const MAILER_SMTP_USER_UNDEFINED = 'mailer/smtp_user_undefined' as const;
/**
* MAILER_SMTP_PASSWORD environment variable is not defined
* (MailerModule)
*/
export const MAILER_SMTP_PASSWORD_UNDEFINED =
'mailer/smtp_password_undefined' as const;
/** /**
* SharedRequest invalid request JSON format * SharedRequest invalid request JSON format
* (ShortcodeService) * (ShortcodeService)

View File

@@ -33,10 +33,17 @@ const AuthProviderConfigurations = {
InfraConfigEnum.MICROSOFT_SCOPE, InfraConfigEnum.MICROSOFT_SCOPE,
InfraConfigEnum.MICROSOFT_TENANT, InfraConfigEnum.MICROSOFT_TENANT,
], ],
[AuthProvider.EMAIL]: [ [AuthProvider.EMAIL]: !!process.env.MAILER_USE_CUSTOM_CONFIGS
InfraConfigEnum.MAILER_SMTP_URL, ? [
InfraConfigEnum.MAILER_ADDRESS_FROM, InfraConfigEnum.MAILER_SMTP_HOST,
], InfraConfigEnum.MAILER_SMTP_PORT,
InfraConfigEnum.MAILER_SMTP_SECURE,
InfraConfigEnum.MAILER_SMTP_USER,
InfraConfigEnum.MAILER_SMTP_PASSWORD,
InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
InfraConfigEnum.MAILER_ADDRESS_FROM,
]
: [InfraConfigEnum.MAILER_SMTP_URL, InfraConfigEnum.MAILER_ADDRESS_FROM],
}; };
/** /**
@@ -75,6 +82,14 @@ export async function getDefaultInfraConfigs(): Promise<
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name' // Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [ const infraConfigDefaultObjs: { name: InfraConfigEnum; value: string }[] = [
{
name: InfraConfigEnum.MAILER_SMTP_ENABLE,
value: process.env.MAILER_SMTP_ENABLE ?? 'true',
},
{
name: InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS,
value: process.env.MAILER_USE_CUSTOM_CONFIGS ?? 'false',
},
{ {
name: InfraConfigEnum.MAILER_SMTP_URL, name: InfraConfigEnum.MAILER_SMTP_URL,
value: process.env.MAILER_SMTP_URL, value: process.env.MAILER_SMTP_URL,
@@ -83,6 +98,30 @@ export async function getDefaultInfraConfigs(): Promise<
name: InfraConfigEnum.MAILER_ADDRESS_FROM, name: InfraConfigEnum.MAILER_ADDRESS_FROM,
value: process.env.MAILER_ADDRESS_FROM, value: process.env.MAILER_ADDRESS_FROM,
}, },
{
name: InfraConfigEnum.MAILER_SMTP_HOST,
value: process.env.MAILER_SMTP_HOST,
},
{
name: InfraConfigEnum.MAILER_SMTP_PORT,
value: process.env.MAILER_SMTP_PORT,
},
{
name: InfraConfigEnum.MAILER_SMTP_SECURE,
value: process.env.MAILER_SMTP_SECURE,
},
{
name: InfraConfigEnum.MAILER_SMTP_USER,
value: process.env.MAILER_SMTP_USER,
},
{
name: InfraConfigEnum.MAILER_SMTP_PASSWORD,
value: process.env.MAILER_SMTP_PASSWORD,
},
{
name: InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
value: process.env.MAILER_TLS_REJECT_UNAUTHORIZED,
},
{ {
name: InfraConfigEnum.GOOGLE_CLIENT_ID, name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: process.env.GOOGLE_CLIENT_ID, value: process.env.GOOGLE_CLIENT_ID,

View File

@@ -43,6 +43,7 @@ export class InfraConfigService implements OnModuleInit {
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION, InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
InfraConfigEnum.ANALYTICS_USER_ID, InfraConfigEnum.ANALYTICS_USER_ID,
InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP, InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
InfraConfigEnum.MAILER_SMTP_ENABLE,
]; ];
// Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead. // Following fields can not be fetched by `infraConfigs` Query. Use dedicated queries for these fields instead.
EXCLUDE_FROM_FETCH_CONFIGS = [ EXCLUDE_FROM_FETCH_CONFIGS = [
@@ -218,6 +219,47 @@ export class InfraConfigService implements OnModuleInit {
return E.right(isUpdated.right.value === 'true'); return E.right(isUpdated.right.value === 'true');
} }
/**
* Enable or Disable SMTP
* @param status Status to enable or disable
* @returns Either true or an error
*/
async enableAndDisableSMTP(status: ServiceStatus) {
const isUpdated = await this.toggleServiceStatus(
InfraConfigEnum.MAILER_SMTP_ENABLE,
status,
true,
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
if (status === ServiceStatus.DISABLE) {
this.enableAndDisableSSO([{ provider: AuthProvider.EMAIL, status }]);
}
return E.right(true);
}
/**
* Enable or Disable Service (i.e. ALLOW_AUDIT_LOGS, ALLOW_ANALYTICS_COLLECTION, ALLOW_DOMAIN_WHITELISTING, SITE_PROTECTION)
* @param configName Name of the InfraConfigEnum
* @param status Status to enable or disable
* @param restartEnabled If true, restart the app after updating the InfraConfig
* @returns Either true or an error
*/
async toggleServiceStatus(
configName: InfraConfigEnum,
status: ServiceStatus,
restartEnabled = false,
) {
const isUpdated = await this.update(
configName,
status === ServiceStatus.ENABLE ? 'true' : 'false',
restartEnabled,
);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right(true);
}
/** /**
* Enable or Disable SSO for login/signup * Enable or Disable SSO for login/signup
* @param provider Auth Provider to enable or disable * @param provider Auth Provider to enable or disable
@@ -316,6 +358,16 @@ export class InfraConfigService implements OnModuleInit {
.split(','); .split(',');
} }
/**
* Check if SMTP is enabled or not
* @returns boolean
*/
isSMTPEnabled() {
return (
this.configService.get<string>('INFRA.MAILER_SMTP_ENABLE') === 'true'
);
}
/** /**
* Reset all the InfraConfigs to their default values (from .env) * Reset all the InfraConfigs to their default values (from .env)
*/ */
@@ -363,6 +415,20 @@ export class InfraConfigService implements OnModuleInit {
) { ) {
for (let i = 0; i < infraConfigs.length; i++) { for (let i = 0; i < infraConfigs.length; i++) {
switch (infraConfigs[i].name) { switch (infraConfigs[i].name) {
case InfraConfigEnum.MAILER_SMTP_ENABLE:
if (
infraConfigs[i].value !== 'true' &&
infraConfigs[i].value !== 'false'
)
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS:
if (
infraConfigs[i].value !== 'true' &&
infraConfigs[i].value !== 'false'
)
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_URL: case InfraConfigEnum.MAILER_SMTP_URL:
const isValidUrl = validateSMTPUrl(infraConfigs[i].value); const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
@@ -371,6 +437,32 @@ export class InfraConfigService implements OnModuleInit {
const isValidEmail = validateSMTPEmail(infraConfigs[i].value); const isValidEmail = validateSMTPEmail(infraConfigs[i].value);
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
case InfraConfigEnum.MAILER_SMTP_HOST:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_PORT:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_SECURE:
if (
infraConfigs[i].value !== 'true' &&
infraConfigs[i].value !== 'false'
)
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_USER:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_SMTP_PASSWORD:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED:
if (
infraConfigs[i].value !== 'true' &&
infraConfigs[i].value !== 'false'
)
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_CLIENT_ID: case InfraConfigEnum.GOOGLE_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Events } from 'src/types/EventEmitter';
import {
AdminUserInvitationMailDescription,
MailDescription,
UserMagicLinkMailDescription,
} from './MailDescriptions';
import { MailerService } from './mailer.service';
@Injectable()
export class MailerEventListener {
constructor(private mailerService: MailerService) {}
@OnEvent(Events.MAILER_SEND_EMAIL, { async: true })
async handleSendEmailEvent(data: {
to: string;
mailDesc: MailDescription | UserMagicLinkMailDescription;
}) {
await this.mailerService.sendEmail(data.to, data.mailDesc);
}
@OnEvent(Events.MAILER_SEND_USER_INVITATION_EMAIL, { async: true })
async handleSendUserInvitationEmailEvent(data: {
to: string;
mailDesc: AdminUserInvitationMailDescription;
}) {
await this.mailerService.sendUserInvitationEmail(data.to, data.mailDesc);
}
}

View File

@@ -4,38 +4,43 @@ import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handleba
import { MailerService } from './mailer.service'; import { MailerService } from './mailer.service';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { import {
MAILER_FROM_ADDRESS_UNDEFINED, MAILER_SMTP_PASSWORD_UNDEFINED,
MAILER_SMTP_URL_UNDEFINED, MAILER_SMTP_URL_UNDEFINED,
MAILER_SMTP_USER_UNDEFINED,
} from 'src/errors'; } from 'src/errors';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper'; import { loadInfraConfiguration } from 'src/infra-config/helper';
import { MailerEventListener } from './mailer.listener';
import { TransportType } from '@nestjs-modules/mailer/dist/interfaces/mailer-options.interface';
@Global() @Global()
@Module({ @Module({})
imports: [],
providers: [MailerService],
exports: [MailerService],
})
export class MailerModule { export class MailerModule {
static async register() { static async register() {
const config = new ConfigService();
const env = await loadInfraConfiguration(); const env = await loadInfraConfiguration();
let mailerSmtpUrl = env.INFRA.MAILER_SMTP_URL; // If mailer SMTP is DISABLED, return the module without any configuration (service, listener, etc.)
let mailerAddressFrom = env.INFRA.MAILER_ADDRESS_FROM; if (env.INFRA.MAILER_SMTP_ENABLE !== 'true') {
console.log('Mailer SMTP is disabled');
if (!env.INFRA.MAILER_SMTP_URL || !env.INFRA.MAILER_ADDRESS_FROM) { return { module: MailerModule };
const config = new ConfigService();
mailerSmtpUrl = config.get('MAILER_SMTP_URL');
mailerAddressFrom = config.get('MAILER_ADDRESS_FROM');
} }
// If mailer is ENABLED, return the module with configuration (service, listener, etc.)
// Determine transport configuration based on custom config flag
let transportOption = getTransportOption(env, config);
// Get mailer address from environment or config
const mailerAddressFrom = getMailerAddressFrom(env, config);
return { return {
module: MailerModule, module: MailerModule,
providers: [MailerService, MailerEventListener],
imports: [ imports: [
NestMailerModule.forRoot({ NestMailerModule.forRoot({
transport: mailerSmtpUrl ?? throwErr(MAILER_SMTP_URL_UNDEFINED), transport: transportOption,
defaults: { defaults: {
from: mailerAddressFrom ?? throwErr(MAILER_FROM_ADDRESS_UNDEFINED), from: mailerAddressFrom,
}, },
template: { template: {
dir: __dirname + '/templates', dir: __dirname + '/templates',
@@ -46,3 +51,52 @@ export class MailerModule {
}; };
} }
} }
function isEnabled(value) {
return value === 'true';
}
function getMailerAddressFrom(env, config): string {
return (
env.INFRA.MAILER_ADDRESS_FROM ??
config.get('MAILER_ADDRESS_FROM') ??
throwErr(MAILER_SMTP_URL_UNDEFINED)
);
}
function getTransportOption(env, config): TransportType {
const useCustomConfigs = isEnabled(
env.INFRA.MAILER_USE_CUSTOM_CONFIGS ??
config.get('MAILER_USE_CUSTOM_CONFIGS'),
);
if (!useCustomConfigs) {
console.log('Using simple mailer configuration');
return (
env.INFRA.MAILER_SMTP_URL ??
config.get('MAILER_SMTP_URL') ??
throwErr(MAILER_SMTP_URL_UNDEFINED)
);
} else {
console.log('Using advanced mailer configuration');
return {
host: env.INFRA.MAILER_SMTP_HOST ?? config.get('MAILER_SMTP_HOST'),
port: +env.INFRA.MAILER_SMTP_PORT ?? +config.get('MAILER_SMTP_PORT'),
secure:
!!env.INFRA.MAILER_SMTP_SECURE ?? !!config.get('MAILER_SMTP_SECURE'),
auth: {
user:
env.INFRA.MAILER_SMTP_USER ??
config.get('MAILER_SMTP_USER') ??
throwErr(MAILER_SMTP_USER_UNDEFINED),
pass:
env.INFRA.MAILER_SMTP_PASSWORD ??
config.get('MAILER_SMTP_PASSWORD') ??
throwErr(MAILER_SMTP_PASSWORD_UNDEFINED),
},
tls: {
rejectUnauthorized:
!!env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED ??
!!config.get('MAILER_TLS_REJECT_UNAUTHORIZED'),
},
};
}
}

View File

@@ -7,10 +7,14 @@ import {
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { EMAIL_FAILED } from 'src/errors'; import { EMAIL_FAILED } from 'src/errors';
import { MailerService as NestMailerService } from '@nestjs-modules/mailer'; import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
import { ConfigService } from '@nestjs/config';
@Injectable() @Injectable()
export class MailerService { export class MailerService {
constructor(private readonly nestMailerService: NestMailerService) {} constructor(
private readonly nestMailerService: NestMailerService,
private readonly configService: ConfigService,
) {}
/** /**
* Takes an input mail description and spits out the Email subject required for it * Takes an input mail description and spits out the Email subject required for it
@@ -42,6 +46,8 @@ export class MailerService {
to: string, to: string,
mailDesc: MailDescription | UserMagicLinkMailDescription, mailDesc: MailDescription | UserMagicLinkMailDescription,
) { ) {
if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return;
try { try {
await this.nestMailerService.sendMail({ await this.nestMailerService.sendMail({
to, to,
@@ -64,6 +70,8 @@ export class MailerService {
to: string, to: string,
mailDesc: AdminUserInvitationMailDescription, mailDesc: AdminUserInvitationMailDescription,
) { ) {
if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return;
try { try {
const res = await this.nestMailerService.sendMail({ const res = await this.nestMailerService.sendMail({
to, to,

View File

@@ -299,10 +299,7 @@ export class ShortcodeService implements UserDataHandler, OnModuleInit {
where: userEmail where: userEmail
? { ? {
User: { User: {
email: { email: userEmail,
equals: userEmail,
mode: 'insensitive',
},
}, },
} }
: undefined, : undefined,

View File

@@ -15,12 +15,13 @@ import {
TEAM_MEMBER_NOT_FOUND, TEAM_MEMBER_NOT_FOUND,
} from 'src/errors'; } from 'src/errors';
import { TeamInvitation } from './team-invitation.model'; import { TeamInvitation } from './team-invitation.model';
import { MailerService } from 'src/mailer/mailer.service';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { validateEmail } from '../utils'; import { validateEmail } from '../utils';
import { AuthUser } from 'src/types/AuthUser'; import { AuthUser } from 'src/types/AuthUser';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Events } from 'src/types/EventEmitter';
@Injectable() @Injectable()
export class TeamInvitationService { export class TeamInvitationService {
@@ -28,9 +29,9 @@ export class TeamInvitationService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly teamService: TeamService, private readonly teamService: TeamService,
private readonly mailerService: MailerService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private eventEmitter: EventEmitter2,
) {} ) {}
/** /**
@@ -75,13 +76,12 @@ export class TeamInvitationService {
if (!isEmailValid) return E.left(INVALID_EMAIL); if (!isEmailValid) return E.left(INVALID_EMAIL);
try { try {
const teamInvite = await this.prisma.teamInvitation.findFirstOrThrow({ const teamInvite = await this.prisma.teamInvitation.findUniqueOrThrow({
where: { where: {
inviteeEmail: { teamID_inviteeEmail: {
equals: inviteeEmail, inviteeEmail: inviteeEmail,
mode: 'insensitive', teamID: teamID,
}, },
teamID,
}, },
}); });
@@ -148,14 +148,17 @@ export class TeamInvitationService {
}, },
}); });
await this.mailerService.sendEmail(inviteeEmail, { this.eventEmitter.emit(Events.MAILER_SEND_EMAIL, {
template: 'team-invitation', to: inviteeEmail,
variables: { mailDesc: {
invitee: creator.displayName ?? 'A Hoppscotch User', template: 'team-invitation',
action_url: `${this.configService.get('VITE_BASE_URL')}/join-team?id=${ variables: {
dbInvitation.id invitee: creator.displayName ?? 'A Hoppscotch User',
}`, action_url: `${this.configService.get(
invite_team_name: team.name, 'VITE_BASE_URL',
)}/join-team?id=${dbInvitation.id}`,
invite_team_name: team.name,
},
}, },
}); });

View File

@@ -0,0 +1,4 @@
export enum Events {
MAILER_SEND_EMAIL = 'mailer.sendEmail',
MAILER_SEND_USER_INVITATION_EMAIL = 'mailer.sendUserInvitationEmail',
}

View File

@@ -1,7 +1,16 @@
export enum InfraConfigEnum { export enum InfraConfigEnum {
MAILER_SMTP_ENABLE = 'MAILER_SMTP_ENABLE',
MAILER_USE_CUSTOM_CONFIGS = 'MAILER_USE_CUSTOM_CONFIGS',
MAILER_SMTP_URL = 'MAILER_SMTP_URL', MAILER_SMTP_URL = 'MAILER_SMTP_URL',
MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM', MAILER_ADDRESS_FROM = 'MAILER_ADDRESS_FROM',
MAILER_SMTP_HOST = 'MAILER_SMTP_HOST',
MAILER_SMTP_PORT = 'MAILER_SMTP_PORT',
MAILER_SMTP_SECURE = 'MAILER_SMTP_SECURE',
MAILER_SMTP_USER = 'MAILER_SMTP_USER',
MAILER_SMTP_PASSWORD = 'MAILER_SMTP_PASSWORD',
MAILER_TLS_REJECT_UNAUTHORIZED = 'MAILER_TLS_REJECT_UNAUTHORIZED',
GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID', GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID',
GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET', GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET',
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL', GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',

View File

@@ -149,7 +149,7 @@ beforeEach(() => {
describe('UserService', () => { describe('UserService', () => {
describe('findUserByEmail', () => { describe('findUserByEmail', () => {
test('should successfully return a valid user given a valid email', async () => { test('should successfully return a valid user given a valid email', async () => {
mockPrisma.user.findFirst.mockResolvedValueOnce(user); mockPrisma.user.findUniqueOrThrow.mockResolvedValueOnce(user);
const result = await userService.findUserByEmail( const result = await userService.findUserByEmail(
'dwight@dundermifflin.com', 'dwight@dundermifflin.com',
@@ -158,7 +158,7 @@ describe('UserService', () => {
}); });
test('should return a null user given a invalid email', async () => { test('should return a null user given a invalid email', async () => {
mockPrisma.user.findFirst.mockResolvedValueOnce(null); mockPrisma.user.findUniqueOrThrow.mockRejectedValueOnce('NotFoundError');
const result = await userService.findUserByEmail('jim@dundermifflin.com'); const result = await userService.findUserByEmail('jim@dundermifflin.com');
expect(result).resolves.toBeNone; expect(result).resolves.toBeNone;

View File

@@ -62,16 +62,16 @@ export class UserService {
* @returns Option of found User * @returns Option of found User
*/ */
async findUserByEmail(email: string): Promise<O.None | O.Some<AuthUser>> { async findUserByEmail(email: string): Promise<O.None | O.Some<AuthUser>> {
const user = await this.prisma.user.findFirst({ try {
where: { const user = await this.prisma.user.findUniqueOrThrow({
email: { where: {
equals: email, email: email,
mode: 'insensitive',
}, },
}, });
}); return O.some(user);
if (!user) return O.none; } catch (error) {
return O.some(user); return O.none;
}
} }
/** /**

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<div v-if="isTooltipComponent" class="flex flex-col px-4 pt-2"> <div class="flex flex-col px-4 pt-2">
<h2 class="inline-flex pb-1 font-semibold text-secondaryDark"> <h2 class="inline-flex pb-1 font-semibold text-secondaryDark">
{{ t("settings.interceptor") }} {{ t("settings.interceptor") }}
</h2> </h2>
@@ -19,9 +19,6 @@
:value="interceptor.interceptorID" :value="interceptor.interceptorID"
:label="unref(interceptor.name(t))" :label="unref(interceptor.name(t))"
:selected="interceptorSelection === interceptor.interceptorID" :selected="interceptorSelection === interceptor.interceptorID"
:class="{
'!px-0 hover:bg-transparent': !isTooltipComponent,
}"
@change="interceptorSelection = interceptor.interceptorID" @change="interceptorSelection = interceptor.interceptorID"
/> />
@@ -42,15 +39,6 @@ import { InterceptorService } from "~/services/interceptor.service"
const t = useI18n() const t = useI18n()
withDefaults(
defineProps<{
isTooltipComponent?: boolean
}>(),
{
isTooltipComponent: true,
}
)
const interceptorService = useService(InterceptorService) const interceptorService = useService(InterceptorService)
const interceptorSelection = const interceptorSelection =

View File

@@ -36,6 +36,16 @@
/> />
</span> </span>
</div> </div>
<div class="space-y-4 py-4">
<div class="flex items-center">
<HoppSmartToggle
:on="extensionEnabled"
@change="extensionEnabled = !extensionEnabled"
>
{{ t("settings.extensions_use_toggle") }}
</HoppSmartToggle>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -45,12 +55,34 @@ import IconCheckCircle from "~icons/lucide/check-circle"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension" import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { computed } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
import { platform } from "~/platform"
const t = useI18n() const t = useI18n()
const interceptorService = useService(InterceptorService)
const extensionService = useService(ExtensionInterceptorService) const extensionService = useService(ExtensionInterceptorService)
const extensionVersion = extensionService.extensionVersion const extensionVersion = extensionService.extensionVersion
const hasChromeExtInstalled = extensionService.chromeExtensionInstalled const hasChromeExtInstalled = extensionService.chromeExtensionInstalled
const hasFirefoxExtInstalled = extensionService.firefoxExtensionInstalled const hasFirefoxExtInstalled = extensionService.firefoxExtensionInstalled
const extensionEnabled = computed({
get() {
return (
interceptorService.currentInterceptorID.value ===
extensionService.interceptorID
)
},
set(active) {
if (active) {
interceptorService.currentInterceptorID.value =
extensionService.interceptorID
} else {
interceptorService.currentInterceptorID.value =
platform.interceptors.default
}
},
})
</script> </script>

View File

@@ -8,6 +8,16 @@
:label="t('app.proxy_privacy_policy')" :label="t('app.proxy_privacy_policy')"
/>. />.
</div> </div>
<div class="space-y-4 py-4">
<div class="flex items-center">
<HoppSmartToggle
:on="proxyEnabled"
@change="proxyEnabled = !proxyEnabled"
>
{{ t("settings.proxy_use_toggle") }}
</HoppSmartToggle>
</div>
</div>
<div class="flex items-center space-x-2 py-4"> <div class="flex items-center space-x-2 py-4">
<HoppSmartInput <HoppSmartInput
v-model="PROXY_URL" v-model="PROXY_URL"
@@ -40,6 +50,7 @@ import { computed } from "vue"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { InterceptorService } from "~/services/interceptor.service" import { InterceptorService } from "~/services/interceptor.service"
import { proxyInterceptor } from "~/platform/std/interceptors/proxy" import { proxyInterceptor } from "~/platform/std/interceptors/proxy"
import { platform } from "~/platform"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
@@ -48,11 +59,23 @@ const interceptorService = useService(InterceptorService)
const PROXY_URL = useSetting("PROXY_URL") const PROXY_URL = useSetting("PROXY_URL")
const proxyEnabled = computed( const proxyEnabled = computed({
() => get() {
interceptorService.currentInterceptorID.value === return (
proxyInterceptor.interceptorID interceptorService.currentInterceptorID.value ===
) proxyInterceptor.interceptorID
)
},
set(active) {
if (active) {
interceptorService.currentInterceptorID.value =
proxyInterceptor.interceptorID
} else {
interceptorService.currentInterceptorID.value =
platform.interceptors.default
}
},
})
const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>( const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
IconRotateCCW, IconRotateCCW,

View File

@@ -1,6 +1,8 @@
import { cloneDeep, defaultsDeep, has } from "lodash-es" import { cloneDeep, defaultsDeep, has } from "lodash-es"
import { Observable } from "rxjs" import { Observable } from "rxjs"
import { distinctUntilChanged, pluck } from "rxjs/operators" import { distinctUntilChanged, pluck } from "rxjs/operators"
import { nextTick } from "vue"
import { platform } from "~/platform"
import type { KeysMatching } from "~/types/ts-utils" import type { KeysMatching } from "~/types/ts-utils"
import DispatchingStore, { defineDispatchers } from "./DispatchingStore" import DispatchingStore, { defineDispatchers } from "./DispatchingStore"
@@ -68,52 +70,63 @@ export type SettingsDef = {
HAS_OPENED_SPOTLIGHT: boolean HAS_OPENED_SPOTLIGHT: boolean
} }
export const getDefaultSettings = (): SettingsDef => ({ export const getDefaultSettings = (): SettingsDef => {
syncCollections: true, const defaultSettings: SettingsDef = {
syncHistory: true, syncCollections: true,
syncEnvironments: true, syncHistory: true,
syncEnvironments: true,
WRAP_LINES: { WRAP_LINES: {
httpRequestBody: true, httpRequestBody: true,
httpResponseBody: true, httpResponseBody: true,
httpHeaders: true, httpHeaders: true,
httpParams: true, httpParams: true,
httpUrlEncoded: true, httpUrlEncoded: true,
httpPreRequest: true, httpPreRequest: true,
httpTest: true, httpTest: true,
httpRequestVariables: true, httpRequestVariables: true,
graphqlQuery: true, graphqlQuery: true,
graphqlResponseBody: true, graphqlResponseBody: true,
graphqlHeaders: false, graphqlHeaders: false,
graphqlVariables: false, graphqlVariables: false,
graphqlSchema: true, graphqlSchema: true,
importCurl: true, importCurl: true,
codeGen: true, codeGen: true,
cookie: true, cookie: true,
}, },
// Set empty because interceptor module will set the default value CURRENT_INTERCEPTOR_ID: "",
CURRENT_INTERCEPTOR_ID: "",
// TODO: Interceptor related settings should move under the interceptor systems // TODO: Interceptor related settings should move under the interceptor systems
PROXY_URL: "https://proxy.hoppscotch.io/", PROXY_URL: "https://proxy.hoppscotch.io/",
URL_EXCLUDES: { URL_EXCLUDES: {
auth: true, auth: true,
httpUser: true, httpUser: true,
httpPassword: true, httpPassword: true,
bearerToken: true, bearerToken: true,
oauth2Token: true, oauth2Token: true,
}, },
THEME_COLOR: "indigo", THEME_COLOR: "indigo",
BG_COLOR: "system", BG_COLOR: "system",
TELEMETRY_ENABLED: true, TELEMETRY_ENABLED: true,
EXPAND_NAVIGATION: false, EXPAND_NAVIGATION: false,
SIDEBAR: true, SIDEBAR: true,
SIDEBAR_ON_LEFT: false, SIDEBAR_ON_LEFT: false,
COLUMN_LAYOUT: true, COLUMN_LAYOUT: true,
HAS_OPENED_SPOTLIGHT: false, HAS_OPENED_SPOTLIGHT: false,
}) }
// Wait for platform to initialize before setting CURRENT_INTERCEPTOR_ID
nextTick(() => {
applySetting(
"CURRENT_INTERCEPTOR_ID",
platform?.interceptors.default || "browser"
)
})
return defaultSettings
}
type ApplySettingPayload = { type ApplySettingPayload = {
[K in keyof SettingsDef]: { [K in keyof SettingsDef]: {

View File

@@ -98,12 +98,6 @@
</p> </p>
</div> </div>
<div class="space-y-8 p-8 md:col-span-2"> <div class="space-y-8 p-8 md:col-span-2">
<section class="flex flex-col space-y-2">
<h4 class="font-semibold text-secondaryDark">
{{ t("settings.interceptor") }}
</h4>
<AppInterceptor :is-tooltip-component="false" />
</section>
<section v-for="[id, settings] in interceptorsWithSettings" :key="id"> <section v-for="[id, settings] in interceptorsWithSettings" :key="id">
<h4 class="font-semibold text-secondaryDark"> <h4 class="font-semibold text-secondaryDark">
{{ settings.entryTitle(t) }} {{ settings.entryTitle(t) }}

34235
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff