Compare commits

..

3 Commits

Author SHA1 Message Date
Gusram
2c0805fafe docs(desktop): update building instruction 2024-05-31 01:41:06 +08:00
Gusram
26b4f64824 chore(desktop): update dependencies version 2024-05-31 01:39:36 +08:00
Gusram
4156551b24 feat(desktop): implement backend wrapper auth 2024-05-31 00:39:19 +08:00
26 changed files with 17950 additions and 15604 deletions

View File

@@ -35,20 +35,9 @@ MICROSOFT_SCOPE="user.read"
MICROSOFT_TENANT="common"
# Mailer config
MAILER_SMTP_ENABLE="true"
MAILER_USE_ADVANCE_CONFIGS="false"
MAILER_SMTP_URL="smtps://user@domain.com:pass@smtp.domain.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_TTL=60 # In seconds
RATE_LIMIT_MAX=100 # Max requests per IP
@@ -58,6 +47,7 @@ RATE_LIMIT_MAX=100 # Max requests per IP
# Base URLs
VITE_BACKEND_LOGIN_API_URL=http://localhost:5444
VITE_BASE_URL=http://localhost:3000
VITE_SHORTCODE_BASE_URL=http://localhost:3000
VITE_ADMIN_URL=http://localhost:3100

View File

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

View File

@@ -22,7 +22,6 @@ import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import * as E from 'fp-ts/Either';
import { EventEmitter2 } from '@nestjs/event-emitter';
const mockPrisma = mockDeep<PrismaService>();
const mockPubSub = mockDeep<PubSubService>();
@@ -35,7 +34,6 @@ const mockTeamCollectionService = mockDeep<TeamCollectionService>();
const mockMailerService = mockDeep<MailerService>();
const mockShortcodeService = mockDeep<ShortcodeService>();
const mockConfigService = mockDeep<ConfigService>();
const mockEventEmitter = mockDeep<EventEmitter2>();
const adminService = new AdminService(
mockUserService,
@@ -46,9 +44,9 @@ const adminService = new AdminService(
mockTeamInvitationService,
mockPubSub as any,
mockPrisma as any,
mockMailerService,
mockShortcodeService,
mockConfigService,
mockEventEmitter,
);
const invitedUsers: InvitedUsers[] = [

View File

@@ -19,6 +19,7 @@ import {
USER_IS_ADMIN,
USER_NOT_FOUND,
} from '../errors';
import { MailerService } from '../mailer/mailer.service';
import { InvitedUser } from './invited-user.model';
import { TeamService } from '../team/team.service';
import { TeamCollectionService } from '../team-collection/team-collection.service';
@@ -30,8 +31,6 @@ import { ShortcodeService } from 'src/shortcode/shortcode.service';
import { ConfigService } from '@nestjs/config';
import { OffsetPaginationArgs } from 'src/types/input-types.args';
import { UserDeletionResult } from 'src/user/user.model';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Events } from 'src/types/EventEmitter';
@Injectable()
export class AdminService {
@@ -44,9 +43,9 @@ export class AdminService {
private readonly teamInvitationService: TeamInvitationService,
private readonly pubsub: PubSubService,
private readonly prisma: PrismaService,
private readonly mailerService: MailerService,
private readonly shortcodeService: ShortcodeService,
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
@@ -100,16 +99,17 @@ export class AdminService {
});
if (alreadyInvitedUser != null) return E.left(USER_ALREADY_INVITED);
this.eventEmitter.emit(Events.MAILER_SEND_USER_INVITATION_EMAIL, {
to: inviteeEmail,
mailDesc: {
try {
await this.mailerService.sendUserInvitationEmail(inviteeEmail, {
template: 'user-invitation',
variables: {
inviteeEmail: inviteeEmail,
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
const dbInvitedUser = await this.prisma.invitedUsers.create({

View File

@@ -292,14 +292,6 @@ export class InfraResolver {
return this.infraConfigService.getAllowedAuthProviders();
}
@Query(() => Boolean, {
description: 'Check if the SMTP is enabled or not',
})
@UseGuards(GqlAuthGuard)
isSMTPEnabled() {
return this.infraConfigService.isSMTPEnabled();
}
/* Mutations */
@Mutation(() => [InfraConfig], {
@@ -367,23 +359,4 @@ export class InfraResolver {
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,11 +27,9 @@ import { MailerModule } from './mailer/mailer.module';
import { PosthogModule } from './posthog/posthog.module';
import { ScheduleModule } from '@nestjs/schedule';
import { HealthModule } from './health/health.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
@Module({
imports: [
EventEmitterModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
load: [async () => loadInfraConfiguration()],

View File

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

View File

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

View File

@@ -678,19 +678,6 @@ export const MAILER_SMTP_URL_UNDEFINED = 'mailer/smtp_url_undefined' as const;
export const MAILER_FROM_ADDRESS_UNDEFINED =
'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
* (ShortcodeService)

View File

@@ -33,17 +33,10 @@ const AuthProviderConfigurations = {
InfraConfigEnum.MICROSOFT_SCOPE,
InfraConfigEnum.MICROSOFT_TENANT,
],
[AuthProvider.EMAIL]: !!process.env.MAILER_USE_CUSTOM_CONFIGS
? [
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],
[AuthProvider.EMAIL]: [
InfraConfigEnum.MAILER_SMTP_URL,
InfraConfigEnum.MAILER_ADDRESS_FROM,
],
};
/**
@@ -82,14 +75,6 @@ export async function getDefaultInfraConfigs(): Promise<
// Prepare rows for 'infra_config' table with default values (from .env) for each 'name'
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,
value: process.env.MAILER_SMTP_URL,
@@ -98,30 +83,6 @@ export async function getDefaultInfraConfigs(): Promise<
name: InfraConfigEnum.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,
value: process.env.GOOGLE_CLIENT_ID,

View File

@@ -43,7 +43,6 @@ export class InfraConfigService implements OnModuleInit {
InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
InfraConfigEnum.ANALYTICS_USER_ID,
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.
EXCLUDE_FROM_FETCH_CONFIGS = [
@@ -219,47 +218,6 @@ export class InfraConfigService implements OnModuleInit {
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
* @param provider Auth Provider to enable or disable
@@ -358,16 +316,6 @@ export class InfraConfigService implements OnModuleInit {
.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)
*/
@@ -415,20 +363,6 @@ export class InfraConfigService implements OnModuleInit {
) {
for (let i = 0; i < infraConfigs.length; i++) {
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:
const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
@@ -437,32 +371,6 @@ export class InfraConfigService implements OnModuleInit {
const isValidEmail = validateSMTPEmail(infraConfigs[i].value);
if (!isValidEmail) return E.left(INFRA_CONFIG_INVALID_INPUT);
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:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;

View File

@@ -1,30 +0,0 @@
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,43 +4,38 @@ import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handleba
import { MailerService } from './mailer.service';
import { throwErr } from 'src/utils';
import {
MAILER_SMTP_PASSWORD_UNDEFINED,
MAILER_FROM_ADDRESS_UNDEFINED,
MAILER_SMTP_URL_UNDEFINED,
MAILER_SMTP_USER_UNDEFINED,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper';
import { MailerEventListener } from './mailer.listener';
import { TransportType } from '@nestjs-modules/mailer/dist/interfaces/mailer-options.interface';
@Global()
@Module({})
@Module({
imports: [],
providers: [MailerService],
exports: [MailerService],
})
export class MailerModule {
static async register() {
const config = new ConfigService();
const env = await loadInfraConfiguration();
// If mailer SMTP is DISABLED, return the module without any configuration (service, listener, etc.)
if (env.INFRA.MAILER_SMTP_ENABLE !== 'true') {
console.log('Mailer SMTP is disabled');
return { module: MailerModule };
let mailerSmtpUrl = env.INFRA.MAILER_SMTP_URL;
let mailerAddressFrom = env.INFRA.MAILER_ADDRESS_FROM;
if (!env.INFRA.MAILER_SMTP_URL || !env.INFRA.MAILER_ADDRESS_FROM) {
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 {
module: MailerModule,
providers: [MailerService, MailerEventListener],
imports: [
NestMailerModule.forRoot({
transport: transportOption,
transport: mailerSmtpUrl ?? throwErr(MAILER_SMTP_URL_UNDEFINED),
defaults: {
from: mailerAddressFrom,
from: mailerAddressFrom ?? throwErr(MAILER_FROM_ADDRESS_UNDEFINED),
},
template: {
dir: __dirname + '/templates',
@@ -51,52 +46,3 @@ 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,14 +7,10 @@ import {
import { throwErr } from 'src/utils';
import { EMAIL_FAILED } from 'src/errors';
import { MailerService as NestMailerService } from '@nestjs-modules/mailer';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MailerService {
constructor(
private readonly nestMailerService: NestMailerService,
private readonly configService: ConfigService,
) {}
constructor(private readonly nestMailerService: NestMailerService) {}
/**
* Takes an input mail description and spits out the Email subject required for it
@@ -46,8 +42,6 @@ export class MailerService {
to: string,
mailDesc: MailDescription | UserMagicLinkMailDescription,
) {
if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return;
try {
await this.nestMailerService.sendMail({
to,
@@ -70,8 +64,6 @@ export class MailerService {
to: string,
mailDesc: AdminUserInvitationMailDescription,
) {
if (this.configService.get('INFRA.MAILER_SMTP_ENABLE') !== 'true') return;
try {
const res = await this.nestMailerService.sendMail({
to,

View File

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

View File

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

View File

@@ -1,16 +1,7 @@
export enum InfraConfigEnum {
MAILER_SMTP_ENABLE = 'MAILER_SMTP_ENABLE',
MAILER_USE_CUSTOM_CONFIGS = 'MAILER_USE_CUSTOM_CONFIGS',
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
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_SECRET = 'GOOGLE_CLIENT_SECRET',
GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL',

View File

@@ -14,3 +14,21 @@ Since TypeScript cannot handle type information for `.vue` imports, they are shi
2. Reload the VS Code window by running `Developer: Reload Window` from the command palette.
You can learn more about Take Over mode [here](https://github.com/johnsoncodehk/volar/discussions/471).
## Building
### Prequisites
- Install Rust: `curl https://sh.rustup.rs -sSf | sh`
- If you're on Mac, remove the installation from brew first if any (brew uninstall rust && brew cleanup)
- libsoup2.4-dev installed if Linux: `sudo apt install libsoup2.4-dev`
- Node v18.20 installed and currently active if you're using nvm
### Build Instruction
1. Install latest pnpm `curl -fsSL https://get.pnpm.io/install.sh | sh -` or upgrade `pnpm add -g pnpm`
2. Setup the .env of the root project folder, you should deploy the self hosted backend first
3. Run `pnpm install` on root project folder
4. Run `pnpm dev:gql-codegen` on this folder
5. Run `pnpm tauri dev` to run debug mode (optional)
6. Run `pnpm tauri build` to build release mode
- `pnpm tauri build --target universal-apple-darwin` for Mac

View File

@@ -17,6 +17,7 @@
"@fontsource-variable/material-symbols-rounded": "5.0.16",
"@fontsource-variable/roboto-mono": "5.0.16",
"@hoppscotch/common": "workspace:^",
"@hoppscotch/data": "workspace:^",
"@platform/auth": "0.1.106",
"@tauri-apps/api": "1.5.1",
"@tauri-apps/cli": "1.5.6",
@@ -36,7 +37,13 @@
"tauri-plugin-store-api": "0.0.0",
"util": "0.12.5",
"vue": "3.3.9",
"workbox-window": "6.6.0"
"workbox-window": "6.6.0",
"zod": "3.22.4",
"@urql/core": "^4.1.1",
"cookie": "^0.5.0",
"subscriptions-transport-ws": "^0.11.0",
"tauri-plugin-websocket-api": "github:tauri-apps/tauri-plugin-websocket#v1",
"wonka": "^6.3.4"
},
"devDependencies": {
"@graphql-codegen/add": "5.0.0",
@@ -76,6 +83,8 @@
"vite-plugin-pwa": "0.13.1",
"vite-plugin-static-copy": "0.12.0",
"vite-plugin-vue-layouts": "0.7.0",
"vue-tsc": "1.8.8"
"vue-tsc": "1.8.8",
"@types/cookie": "^0.5.1"
}
}

View File

@@ -25,11 +25,13 @@ tauri = { version = "1.5.3", features = [
] }
tauri-plugin-store = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-deep-link = { git = "https://github.com/FabianLars/tauri-plugin-deep-link", branch = "main" }
tauri-plugin-websocket = { git = "https://github.com/tauri-apps/plugins-workspace", branch = "v1" }
tauri-plugin-window-state = "0.1.0"
reqwest = "0.11.22"
serde_json = "1.0.108"
url = "2.5.0"
hex_color = "3.0.0"
time = "0.3.36"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.25.0"

View File

@@ -21,6 +21,7 @@ fn main() {
tauri_plugin_deep_link::prepare("io.hoppscotch.desktop");
tauri::Builder::default()
.plugin(tauri_plugin_websocket::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_store::Builder::default().build())
.setup(|app| {

View File

@@ -49,7 +49,7 @@
"targets": "all"
},
"security": {
"csp": "none"
"csp": null
},
"updater": {
"active": false

View File

@@ -0,0 +1,168 @@
import { ref } from "vue"
import { makeResult, makeErrorResult, Exchange, Operation } from "@urql/core"
import { makeFetchBody, makeFetchOptions } from "@urql/core/internal"
import { filter, make, merge, mergeMap, pipe, takeUntil, map } from "wonka"
import { gqlClientError$ } from "@hoppscotch/common/helpers/backend/GQLClient"
import { Store } from "tauri-plugin-store-api"
import { Body, getClient } from "@tauri-apps/api/http"
import { parse, serialize } from "cookie"
import { SubscriptionClient } from "subscriptions-transport-ws"
import { platform } from "@hoppscotch/common/platform"
import WSWrapper from "./ws_wrapper"
const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
export async function addCookieToFetchHeaders(
store: Store,
headers: HeadersInit = {}
) {
try {
const accessToken = await store.get<{ value: string }>("access_token")
const refreshToken = await store.get<{ value: string }>("refresh_token")
if (accessToken?.value && refreshToken?.value) {
// Assert headers as an indexable type
const headersIndexable = headers as { [key: string]: string }
const existingCookies = parse(headersIndexable["Cookie"] || "")
if (!existingCookies.access_token) {
existingCookies.access_token = accessToken.value
}
if (!existingCookies.refresh_token) {
existingCookies.refresh_token = refreshToken.value
}
// Serialize the cookies back into the headers
const serializedCookies = Object.entries(existingCookies)
.map(([name, value]) => serialize(name, value))
.join("; ")
headersIndexable["Cookie"] = serializedCookies
}
return headers
} catch (error) {
console.error("error while injecting cookie")
}
}
function createHttpSource(operation: Operation, store: Store) {
return make(({ next, complete }) => {
getClient().then(async (httpClient) => {
const fetchOptions = makeFetchOptions(operation)
let headers = fetchOptions.headers
headers = await addCookieToFetchHeaders(store, headers)
const fetchBody = makeFetchBody(operation)
httpClient
.post(operation.context.url, Body.json(fetchBody), {
headers,
})
.then((result) => {
next(result.data)
complete()
})
.catch((error) => {
next(makeErrorResult(operation, error))
complete()
})
})
return () => {}
})
}
export const tauriGQLFetchExchange =
(store: Store): Exchange =>
({ forward }) =>
(ops$) => {
const subscriptionResults$ = pipe(
ops$,
filter((op) => op.kind === "query" || op.kind === "mutation"),
mergeMap((operation) => {
const { key, context } = operation
const teardown$ = pipe(
ops$,
filter((op: Operation) => op.kind === "teardown" && op.key === key)
)
const source = createHttpSource(operation, store)
return pipe(
source,
takeUntil(teardown$),
map((result) => makeResult(operation, result as any))
)
})
)
const forward$ = pipe(
ops$,
filter(
(op: Operation) => op.kind === "teardown" || op.kind != "subscription"
),
forward
)
return merge([subscriptionResults$, forward$])
}
const createSubscriptionClient = () => {
return new SubscriptionClient(
import.meta.env.VITE_BACKEND_WS_URL,
{
reconnect: true,
connectionParams: () => platform.auth.getBackendHeaders(),
connectionCallback(error) {
if (error?.length > 0) {
gqlClientError$.next({
type: "SUBSCRIPTION_CONN_CALLBACK_ERR_REPORT",
errors: error,
})
}
},
wsOptionArguments: [
{
store: new Store(APP_DATA_PATH),
},
],
},
WSWrapper
)
}
let subscriptionClient: SubscriptionClient | null
const isBackendGQLEventAdded = ref(false)
const resetSubscriptionClient = () => {
if (subscriptionClient) {
subscriptionClient.close()
}
subscriptionClient = createSubscriptionClient()
if (!isBackendGQLEventAdded.value) {
subscriptionClient.onConnected(() => {
platform.auth.onBackendGQLClientShouldReconnect(() => {
const currentUser = platform.auth.getCurrentUser()
if (currentUser && subscriptionClient) {
subscriptionClient?.client?.close()
}
if (currentUser && !subscriptionClient) {
resetSubscriptionClient()
}
if (!currentUser && subscriptionClient) {
subscriptionClient.close()
resetSubscriptionClient()
}
})
})
isBackendGQLEventAdded.value = true
}
}
export const getSubscriptionClient = () => {
if (!subscriptionClient) resetSubscriptionClient()
return subscriptionClient
}

View File

@@ -0,0 +1,154 @@
import { Store } from "tauri-plugin-store-api"
import TauriWebSocket, {
Message,
ConnectionConfig,
} from "tauri-plugin-websocket-api"
import { addCookieToFetchHeaders } from "./GQLClient"
/**
* This is a wrapper around tauri-plugin-websocket-api with cookie injection support. This is required because
* subscriptions-transport-ws client expects a custom websocket implementation in the shape of native browser WebSocket.
*/
export default class WebSocketWrapper extends EventTarget implements WebSocket {
public client: TauriWebSocket | undefined
private tauriWebSocketConfig:
| (ConnectionConfig & { store: Store })
| undefined
private isConnected: boolean = false
binaryType: BinaryType = "blob"
extensions = ""
onclose: ((this: WebSocket, ev: CloseEvent) => any) | null = null
onerror: ((this: WebSocket, ev: Event) => any) | null = null
onmessage: ((this: WebSocket, ev: MessageEvent) => any) | null = null
onopen: ((this: WebSocket, ev: Event) => any) | null = null
protocol = ""
url: string
public static readonly CONNECTING = 0
public static readonly OPEN = 1
public static readonly CLOSING = 2
public static readonly CLOSED = 3
readonly CONNECTING = 0
readonly OPEN = 1
readonly CLOSING = 2
readonly CLOSED = 3
constructor(
url: string,
protocols?: string | string[],
config?: ConnectionConfig & { store: Store }
) {
super()
this.url = url
this.tauriWebSocketConfig = config
this.setup()
}
private async setup() {
if (this.tauriWebSocketConfig?.store) {
const headersStringified =
this.tauriWebSocketConfig.headers || ("{}" as any)
let headers = JSON.parse(headersStringified)
headers = await addCookieToFetchHeaders(
this.tauriWebSocketConfig.store,
headers
)
this.tauriWebSocketConfig = {
...this.tauriWebSocketConfig,
headers,
}
}
this.client = await TauriWebSocket.connect(this.url, {
headers: {
"sec-websocket-protocol": "graphql-ws",
...this.tauriWebSocketConfig?.headers,
},
}).catch((error) => {
this.isConnected = false
if (this.onerror) {
this.onerror(new Event("error"))
}
throw new Error(error)
})
this.isConnected = true
this.client.addListener(this.handleMessage.bind(this))
if (this.onopen) {
this.onopen(new Event("open"))
}
}
get readyState(): number {
return this.client
? this.isConnected
? this.OPEN
: this.CLOSED
: this.CONNECTING
}
get bufferedAmount(): number {
// TODO implement
return 0
}
close(code?: number, reason?: string): void {
this.client?.disconnect().then(() => {
if (this.onclose) {
this.onclose(new CloseEvent("close"))
}
})
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (
typeof data === "string" ||
data instanceof ArrayBuffer ||
data instanceof Blob
) {
this.client?.send(data as string).catch((error) => {
console.error("error while sending data", data)
if (this.onerror) {
this.onerror(new Event("error"))
}
})
} else {
// TODO implement, drop the record for now
console.warn(
"WebSocketWrapper.send() not implemented for non-string data"
)
}
}
private handleMessage(message: Message): void {
switch (message.type) {
case "Close": {
if (this.onclose) {
this.onclose(new CloseEvent("close"))
}
return
}
case "Ping": {
this.client?.send("Pong").catch((error) => {
console.error("error while sending Pong data", message)
if (this.onerror) {
this.onerror(new Event("error"))
}
})
return
}
default: {
if (this.onmessage) {
this.onmessage(
new MessageEvent("message", {
data: message.data,
origin: this.url,
})
)
}
}
}
}
}

View File

@@ -1,4 +1,5 @@
import { getService } from "@hoppscotch/common/modules/dioc"
import axios from "axios"
import {
AuthEvent,
AuthPlatformDef,
@@ -13,15 +14,50 @@ import { open } from '@tauri-apps/api/shell'
import { BehaviorSubject, Subject } from "rxjs"
import { Store } from "tauri-plugin-store-api"
import { Ref, ref, watch } from "vue"
import { z } from "zod"
import * as E from "fp-ts/Either"
import { subscriptionExchange } from "@urql/core"
import {
getSubscriptionClient,
tauriGQLFetchExchange,
} from "../helpers/GQLClient"
export const authEvents$ = new Subject<AuthEvent | { event: "token_refresh" }>()
const currentUser$ = new BehaviorSubject<HoppUser | null>(null)
export const probableUser$ = new BehaviorSubject<HoppUser | null>(null)
const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
export const APP_DATA_PATH = "~/.hopp-desktop-app-data.dat"
const persistenceService = getService(PersistenceService)
const expectedAllowedProvidersSchema = z.object({
// currently supported values are "GOOGLE", "GITHUB", "EMAIL", "MICROSOFT", "SAML"
// keeping it as string to avoid backend accidentally breaking frontend when adding new providers
providers: z.array(z.string()),
})
export const getAllowedAuthProviders = async () => {
try {
const res = await axios.get(
`${import.meta.env.VITE_BACKEND_API_URL}/auth/providers`,
{
withCredentials: true,
}
)
const parseResult = expectedAllowedProvidersSchema.safeParse(res.data)
if (!parseResult.success) {
return E.left("SOMETHING_WENT_WRONG")
}
return E.right(parseResult.data.providers)
} catch (_) {
return E.left("SOMETHING_WENT_WRONG")
}
}
async function logout() {
let client = await getClient();
await client.get(`${import.meta.env.VITE_BACKEND_API_URL}/auth/logout`)
@@ -37,7 +73,7 @@ async function signInUserWithGithubFB() {
}
async function signInUserWithGoogleFB() {
await open(`${import.meta.env.VITE_BACKEND_API_URL}/auth/google?redirect_uri=desktop`);
await open(`${import.meta.env.VITE_BACKEND_LOGIN_API_URL}/authenticate`);
}
async function signInUserWithMicrosoftFB() {
@@ -224,6 +260,15 @@ export const def: AuthPlatformDef = {
},
getGQLClientOptions() {
return {
exchanges: [
subscriptionExchange({
forwardSubscription(fetchBody) {
const subscriptionClient = getSubscriptionClient()
return subscriptionClient!.request(fetchBody)
},
}),
tauriGQLFetchExchange(new Store(APP_DATA_PATH)),
],
fetchOptions: {
credentials: "include",
},
@@ -371,4 +416,5 @@ export const def: AuthPlatformDef = {
event: "logout",
})
},
getAllowedAuthProviders,
}

32747
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff