feature: reduce .env usage and move configurations to admin dashboard (#5194)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Mir Arif Hasan 2025-07-28 17:16:30 +06:00 committed by GitHub
parent 37671ac9e7
commit 0b7d31a20c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 3330 additions and 899 deletions

View file

@ -2,24 +2,9 @@
# Prisma Config # Prisma Config
DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch DATABASE_URL=postgresql://postgres:testpass@hoppscotch-db:5432/hoppscotch
# Auth Tokens Config
JWT_SECRET="secret1233"
TOKEN_SALT_COMPLEXITY=10
MAGIC_LINK_TOKEN_VALIDITY= 3
# Default validity is 7 days (604800000 ms) in ms
REFRESH_TOKEN_VALIDITY="604800000"
# Default validity is 1 day (86400000 ms) in ms
ACCESS_TOKEN_VALIDITY="86400000"
SESSION_SECRET='add some secret here'
# Reccomended to be true, set to false if you are using http
# Note: Some auth providers may not support http requests
ALLOW_SECURE_COOKIES=true
# Sensitive Data Encryption Key while storing in Database (32 character) # Sensitive Data Encryption Key while storing in Database (32 character)
DATA_ENCRYPTION_KEY="data encryption key with 32 char" DATA_ENCRYPTION_KEY="data encryption key with 32 char"
# Hoppscotch App Domain Config
REDIRECT_URL="http://localhost:3000"
# Whitelisted origins for the Hoppscotch App. # Whitelisted origins for the Hoppscotch App.
# This list controls which origins can interact with the app through cross-origin comms. # This list controls which origins can interact with the app through cross-origin comms.
# - localhost ports (3170, 3000, 3100): app, backend, development servers and services # - localhost ports (3170, 3000, 3100): app, backend, development servers and services
@ -28,50 +13,10 @@ REDIRECT_URL="http://localhost:3000"
# NOT where the app runs. The app itself uses the `app://` protocol with dynamic # NOT where the app runs. The app itself uses the `app://` protocol with dynamic
# bundle names like `app://{bundle-name}/` # bundle names like `app://{bundle-name}/`
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100,app://localhost_3200,app://hoppscotch" WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100,app://localhost_3200,app://hoppscotch"
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
# Google Auth Config
GOOGLE_CLIENT_ID="************************************************"
GOOGLE_CLIENT_SECRET="************************************************"
GOOGLE_CALLBACK_URL="http://localhost:3170/v1/auth/google/callback"
GOOGLE_SCOPE="email,profile"
# Github Auth Config
GITHUB_CLIENT_ID="************************************************"
GITHUB_CLIENT_SECRET="************************************************"
GITHUB_CALLBACK_URL="http://localhost:3170/v1/auth/github/callback"
GITHUB_SCOPE="user:email"
# Microsoft Auth Config
MICROSOFT_CLIENT_ID="************************************************"
MICROSOFT_CLIENT_SECRET="************************************************"
MICROSOFT_CALLBACK_URL="http://localhost:3170/v1/auth/microsoft/callback"
MICROSOFT_SCOPE="user.read"
MICROSOFT_TENANT="common"
# Mailer config
MAILER_SMTP_ENABLE="true"
MAILER_USE_CUSTOM_CONFIGS="false"
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
#-----------------------Frontend Config------------------------------# #-----------------------Frontend Config------------------------------#
# Base URLs # Base URLs
VITE_BASE_URL=http://localhost:3000 VITE_BASE_URL=http://localhost:3000
VITE_SHORTCODE_BASE_URL=http://localhost:3000 VITE_SHORTCODE_BASE_URL=http://localhost:3000

View file

@ -61,6 +61,7 @@
"handlebars": "4.7.8", "handlebars": "4.7.8",
"io-ts": "2.2.22", "io-ts": "2.2.22",
"luxon": "3.7.1", "luxon": "3.7.1",
"morgan": "1.10.1",
"nodemailer": "7.0.5", "nodemailer": "7.0.5",
"passport": "0.7.0", "passport": "0.7.0",
"passport-github2": "0.1.12", "passport-github2": "0.1.12",

View file

@ -44,7 +44,6 @@ import { PubSubModule } from './pubsub/pubsub.module';
}), }),
GraphQLModule.forRootAsync<ApolloDriverConfig>({ GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver, driver: ApolloDriver,
imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: async (configService: ConfigService) => { useFactory: async (configService: ConfigService) => {
return { return {
@ -92,12 +91,11 @@ import { PubSubModule } from './pubsub/pubsub.module';
}, },
}), }),
ThrottlerModule.forRootAsync({ ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: async (configService: ConfigService) => [ useFactory: async (configService: ConfigService) => [
{ {
ttl: +configService.get('RATE_LIMIT_TTL'), ttl: +configService.get('INFRA.RATE_LIMIT_TTL'),
limit: +configService.get('RATE_LIMIT_MAX'), limit: +configService.get('INFRA.RATE_LIMIT_MAX'),
}, },
], ],
}), }),

View file

@ -76,7 +76,7 @@ export class AuthController {
async verify(@Body() data: VerifyMagicDto, @Res() res: Response) { async verify(@Body() data: VerifyMagicDto, @Res() res: Response) {
const authTokens = await this.authService.verifyMagicLinkTokens(data); const authTokens = await this.authService.verifyMagicLinkTokens(data);
if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left);
authCookieHandler(res, authTokens.right, false, null); authCookieHandler(res, authTokens.right, false, null, this.configService);
} }
/** /**
@ -95,7 +95,7 @@ export class AuthController {
user, user,
); );
if (E.isLeft(newTokenPair)) throwHTTPErr(newTokenPair.left); if (E.isLeft(newTokenPair)) throwHTTPErr(newTokenPair.left);
authCookieHandler(res, newTokenPair.right, false, null); authCookieHandler(res, newTokenPair.right, false, null, this.configService);
} }
/** /**
@ -121,6 +121,7 @@ export class AuthController {
authTokens.right, authTokens.right,
true, true,
req.authInfo.state.redirect_uri, req.authInfo.state.redirect_uri,
this.configService,
); );
} }
@ -147,6 +148,7 @@ export class AuthController {
authTokens.right, authTokens.right,
true, true,
req.authInfo.state.redirect_uri, req.authInfo.state.redirect_uri,
this.configService,
); );
} }
@ -173,6 +175,7 @@ export class AuthController {
authTokens.right, authTokens.right,
true, true,
req.authInfo.state.redirect_uri, req.authInfo.state.redirect_uri,
this.configService,
); );
} }

View file

@ -10,7 +10,7 @@ import { GoogleStrategy } from './strategies/google.strategy';
import { GithubStrategy } from './strategies/github.strategy'; import { GithubStrategy } from './strategies/github.strategy';
import { MicrosoftStrategy } from './strategies/microsoft.strategy'; import { MicrosoftStrategy } from './strategies/microsoft.strategy';
import { AuthProvider, authProviderCheck } from './helper'; import { AuthProvider, authProviderCheck } from './helper';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { import {
getConfiguredSSOProvidersFromInfraConfig, getConfiguredSSOProvidersFromInfraConfig,
isInfraConfigTablePopulated, isInfraConfigTablePopulated,
@ -22,15 +22,14 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
UserModule, UserModule,
PassportModule, PassportModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({ useFactory: async (configService: ConfigService) => ({
secret: configService.get('JWT_SECRET'), secret: configService.get('INFRA.JWT_SECRET'),
}), }),
}), }),
InfraConfigModule, InfraConfigModule,
], ],
providers: [AuthService, JwtStrategy, RTJwtStrategy], providers: [AuthService],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule { export class AuthModule {
@ -57,7 +56,7 @@ export class AuthModule {
return { return {
module: AuthModule, module: AuthModule,
providers, providers: [...providers, JwtStrategy, RTJwtStrategy],
}; };
} }
} }

View file

@ -50,11 +50,13 @@ export class AuthService {
*/ */
private async generateMagicLinkTokens(user: AuthUser) { private async generateMagicLinkTokens(user: AuthUser) {
const salt = await bcrypt.genSalt( const salt = await bcrypt.genSalt(
parseInt(this.configService.get('TOKEN_SALT_COMPLEXITY')), parseInt(this.configService.get('INFRA.TOKEN_SALT_COMPLEXITY')),
); );
const expiresOn = DateTime.now() const expiresOn = DateTime.now()
.plus({ .plus({
hours: parseInt(this.configService.get('MAGIC_LINK_TOKEN_VALIDITY')), hours: parseInt(
this.configService.get('INFRA.MAGIC_LINK_TOKEN_VALIDITY'),
),
}) })
.toISO() .toISO()
.toString(); .toString();
@ -106,7 +108,7 @@ export class AuthService {
}; };
const refreshToken = await this.jwtService.sign(refreshTokenPayload, { const refreshToken = await this.jwtService.sign(refreshTokenPayload, {
expiresIn: this.configService.get('REFRESH_TOKEN_VALIDITY'), //7 Days expiresIn: this.configService.get('INFRA.REFRESH_TOKEN_VALIDITY'), //7 Days
}); });
const refreshTokenHash = await argon2.hash(refreshToken); const refreshTokenHash = await argon2.hash(refreshToken);
@ -142,7 +144,7 @@ export class AuthService {
return E.right(<AuthTokens>{ return E.right(<AuthTokens>{
access_token: await this.jwtService.sign(accessTokenPayload, { access_token: await this.jwtService.sign(accessTokenPayload, {
expiresIn: this.configService.get('ACCESS_TOKEN_VALIDITY'), //1 Day expiresIn: this.configService.get('INFRA.ACCESS_TOKEN_VALIDITY'), //1 Day
}), }),
refresh_token: refreshToken.right, refresh_token: refreshToken.right,
}); });

View file

@ -41,30 +41,29 @@ export const authCookieHandler = (
authTokens: AuthTokens, authTokens: AuthTokens,
redirect: boolean, redirect: boolean,
redirectUrl: string | null, redirectUrl: string | null,
configService: ConfigService,
) => { ) => {
const configService = new ConfigService();
const currentTime = DateTime.now(); const currentTime = DateTime.now();
const accessTokenValidity = currentTime const accessTokenValidity = currentTime
.plus({ .plus({
milliseconds: parseInt(configService.get('ACCESS_TOKEN_VALIDITY')), milliseconds: parseInt(configService.get('INFRA.ACCESS_TOKEN_VALIDITY')),
}) })
.toMillis(); .toMillis();
const refreshTokenValidity = currentTime const refreshTokenValidity = currentTime
.plus({ .plus({
milliseconds: parseInt(configService.get('REFRESH_TOKEN_VALIDITY')), milliseconds: parseInt(configService.get('INFRA.REFRESH_TOKEN_VALIDITY')),
}) })
.toMillis(); .toMillis();
res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, { res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, {
httpOnly: true, httpOnly: true,
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true', secure: configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true',
sameSite: 'lax', sameSite: 'lax',
maxAge: accessTokenValidity, maxAge: accessTokenValidity,
}); });
res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, { res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, {
httpOnly: true, httpOnly: true,
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true', secure: configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true',
sameSite: 'lax', sameSite: 'lax',
maxAge: refreshTokenValidity, maxAge: refreshTokenValidity,
}); });
@ -74,12 +73,11 @@ export const authCookieHandler = (
} }
// check to see if redirectUrl is a whitelisted url // check to see if redirectUrl is a whitelisted url
const whitelistedOrigins = configService const whitelistedOrigins =
.get('WHITELISTED_ORIGINS') configService.get('WHITELISTED_ORIGINS')?.split(',') ?? [];
.split(',');
if (!whitelistedOrigins.includes(redirectUrl)) if (!whitelistedOrigins.includes(redirectUrl))
// if it is not redirect by default to REDIRECT_URL // if it is not redirect by default to App
redirectUrl = configService.get('REDIRECT_URL'); redirectUrl = configService.get('VITE_BASE_URL');
return res.status(HttpStatus.OK).redirect(redirectUrl); return res.status(HttpStatus.OK).redirect(redirectUrl);
}; };
@ -121,11 +119,7 @@ export function authProviderCheck(
throwErr(AUTH_PROVIDER_NOT_SPECIFIED); throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
} }
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS const envVariables = VITE_ALLOWED_AUTH_PROVIDERS?.split(',') ?? [];
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
provider.trim().toUpperCase(),
)
: [];
if (!envVariables.includes(provider.toUpperCase())) return false; if (!envVariables.includes(provider.toUpperCase())) return false;

View file

@ -93,7 +93,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
), ),
), ),
]), ]),
secretOrKey: configService.get<string>('JWT_SECRET'), secretOrKey: configService.get('INFRA.JWT_SECRET'),
}); });
} }

View file

@ -33,7 +33,7 @@ export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
return RTCookie; return RTCookie;
}, },
]), ]),
secretOrKey: configService.get<string>('JWT_SECRET'), secretOrKey: configService.get('INFRA.JWT_SECRET'),
}); });
} }

View file

@ -36,13 +36,6 @@ export const JSON_INVALID = 'json_invalid';
*/ */
export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified'; export const AUTH_PROVIDER_NOT_SPECIFIED = 'auth/provider_not_specified';
/**
* Auth Provider not specified
* (Auth)
*/
export const AUTH_PROVIDER_NOT_CONFIGURED =
'auth/provider_not_configured_correctly';
/** /**
* Email not provided by OAuth provider * Email not provided by OAuth provider
* (SSO Strategies) * (SSO Strategies)
@ -50,12 +43,6 @@ export const AUTH_PROVIDER_NOT_CONFIGURED =
export const AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH = export const AUTH_EMAIL_NOT_PROVIDED_BY_OAUTH =
'auth/email_not_provided_by_oauth'; 'auth/email_not_provided_by_oauth';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file
*/
export const ENV_NOT_FOUND_KEY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is not present in .env file';
/** /**
* Environment variable "DATA_ENCRYPTION_KEY" is not present in .env file * Environment variable "DATA_ENCRYPTION_KEY" is not present in .env file
*/ */
@ -68,18 +55,6 @@ export const ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY =
export const ENV_INVALID_DATA_ENCRYPTION_KEY = export const ENV_INVALID_DATA_ENCRYPTION_KEY =
'"DATA_ENCRYPTION_KEY" value changed in .env file. Please undo the changes and restart the server'; '"DATA_ENCRYPTION_KEY" value changed in .env file. Please undo the changes and restart the server';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file
*/
export const ENV_EMPTY_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" is empty in .env file';
/**
* Environment variable "VITE_ALLOWED_AUTH_PROVIDERS" contains unsupported provider in .env file
*/
export const ENV_NOT_SUPPORT_AUTH_PROVIDERS =
'"VITE_ALLOWED_AUTH_PROVIDERS" contains an unsupported auth provider in .env file';
/** /**
* Tried to delete a user data document from fb firestore but failed. * Tried to delete a user data document from fb firestore but failed.
* (FirebaseService) * (FirebaseService)

View file

@ -0,0 +1,200 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Expose } from 'class-transformer';
import { IsOptional, IsString } from 'class-validator';
import { InfraConfigEnum } from 'src/types/InfraConfig';
export class GetOnboardingStatusResponse {
@ApiProperty()
@Expose()
onboardingCompleted: boolean;
@ApiProperty()
@Expose()
canReRunOnboarding: boolean;
}
export class SaveOnboardingConfigRequest {
@ApiProperty()
@IsString()
[InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.GOOGLE_CLIENT_ID]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.GOOGLE_CLIENT_SECRET]: string;
@ApiPropertyOptional()
@IsOptional()
[InfraConfigEnum.GOOGLE_CALLBACK_URL]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.GOOGLE_SCOPE]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.GITHUB_CLIENT_ID]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.GITHUB_CLIENT_SECRET]: string;
@ApiPropertyOptional()
@IsOptional()
[InfraConfigEnum.GITHUB_CALLBACK_URL]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.GITHUB_SCOPE]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MICROSOFT_CLIENT_ID]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MICROSOFT_CLIENT_SECRET]: string;
@ApiPropertyOptional()
@IsOptional()
[InfraConfigEnum.MICROSOFT_CALLBACK_URL]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MICROSOFT_SCOPE]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MICROSOFT_TENANT]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_ENABLE]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_ADDRESS_FROM]: string;
@ApiPropertyOptional()
@IsOptional()
[InfraConfigEnum.MAILER_SMTP_URL]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_HOST]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_PORT]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_SECURE]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_USER]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_SMTP_PASSWORD]: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
[InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED]: string;
}
export class SaveOnboardingConfigResponse {
@ApiProperty()
@Expose()
token: string;
}
export class GetOnboardingConfigResponse {
@ApiProperty()
@Expose()
[InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS]: string;
@ApiProperty({ default: null })
@Expose()
[InfraConfigEnum.GOOGLE_CLIENT_ID]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.GOOGLE_CLIENT_SECRET]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.GOOGLE_CALLBACK_URL]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.GOOGLE_SCOPE]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.GITHUB_CLIENT_ID]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.GITHUB_CLIENT_SECRET]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.GITHUB_CALLBACK_URL]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.GITHUB_SCOPE]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MICROSOFT_CLIENT_ID]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MICROSOFT_CLIENT_SECRET]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MICROSOFT_CALLBACK_URL]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MICROSOFT_SCOPE]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MICROSOFT_TENANT]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_ENABLE]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_ADDRESS_FROM]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_URL]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_HOST]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_PORT]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_SECURE]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_USER]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_SMTP_PASSWORD]: string;
@ApiProperty()
@Expose()
[InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED]: string;
}

View file

@ -1,13 +1,9 @@
import { AuthProvider } from 'src/auth/helper'; import { AuthProvider } from 'src/auth/helper';
import { import { ENV_INVALID_DATA_ENCRYPTION_KEY } from 'src/errors';
AUTH_PROVIDER_NOT_CONFIGURED,
ENV_INVALID_DATA_ENCRYPTION_KEY,
} from 'src/errors';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { InfraConfigEnum } from 'src/types/InfraConfig'; import { InfraConfigEnum } from 'src/types/InfraConfig';
import { decrypt, encrypt, throwErr } from 'src/utils'; import { decrypt, encrypt } from 'src/utils';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { InfraConfig } from '@prisma/client';
export enum ServiceStatus { export enum ServiceStatus {
ENABLE = 'ENABLE', ENABLE = 'ENABLE',
@ -17,43 +13,52 @@ export enum ServiceStatus {
type DefaultInfraConfig = { type DefaultInfraConfig = {
name: InfraConfigEnum; name: InfraConfigEnum;
value: string; value: string;
lastSyncedEnvFileValue: string;
isEncrypted: boolean; isEncrypted: boolean;
}; };
const AuthProviderConfigurations = { /**
[AuthProvider.GOOGLE]: [ * Returns a mapping of authentication providers to their required configuration keys based on the current environment configuration.
InfraConfigEnum.GOOGLE_CLIENT_ID, */
InfraConfigEnum.GOOGLE_CLIENT_SECRET, export function getAuthProviderRequiredKeys(
InfraConfigEnum.GOOGLE_CALLBACK_URL, env: Record<string, any>,
InfraConfigEnum.GOOGLE_SCOPE, ): Record<AuthProvider, InfraConfigEnum[]> {
], return {
[AuthProvider.GITHUB]: [ [AuthProvider.GOOGLE]: [
InfraConfigEnum.GITHUB_CLIENT_ID, InfraConfigEnum.GOOGLE_CLIENT_ID,
InfraConfigEnum.GITHUB_CLIENT_SECRET, InfraConfigEnum.GOOGLE_CLIENT_SECRET,
InfraConfigEnum.GITHUB_CALLBACK_URL, InfraConfigEnum.GOOGLE_CALLBACK_URL,
InfraConfigEnum.GITHUB_SCOPE, InfraConfigEnum.GOOGLE_SCOPE,
], ],
[AuthProvider.MICROSOFT]: [ [AuthProvider.GITHUB]: [
InfraConfigEnum.MICROSOFT_CLIENT_ID, InfraConfigEnum.GITHUB_CLIENT_ID,
InfraConfigEnum.MICROSOFT_CLIENT_SECRET, InfraConfigEnum.GITHUB_CLIENT_SECRET,
InfraConfigEnum.MICROSOFT_CALLBACK_URL, InfraConfigEnum.GITHUB_CALLBACK_URL,
InfraConfigEnum.MICROSOFT_SCOPE, InfraConfigEnum.GITHUB_SCOPE,
InfraConfigEnum.MICROSOFT_TENANT, ],
], [AuthProvider.MICROSOFT]: [
[AuthProvider.EMAIL]: InfraConfigEnum.MICROSOFT_CLIENT_ID,
process.env.MAILER_USE_CUSTOM_CONFIGS === 'true' InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
? [ InfraConfigEnum.MICROSOFT_CALLBACK_URL,
InfraConfigEnum.MAILER_SMTP_HOST, InfraConfigEnum.MICROSOFT_SCOPE,
InfraConfigEnum.MAILER_SMTP_PORT, InfraConfigEnum.MICROSOFT_TENANT,
InfraConfigEnum.MAILER_SMTP_SECURE, ],
InfraConfigEnum.MAILER_SMTP_USER, [AuthProvider.EMAIL]:
InfraConfigEnum.MAILER_SMTP_PASSWORD, env['INFRA'].MAILER_USE_CUSTOM_CONFIGS === 'true'
InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED, ? [
InfraConfigEnum.MAILER_ADDRESS_FROM, InfraConfigEnum.MAILER_SMTP_HOST,
] InfraConfigEnum.MAILER_SMTP_PORT,
: [InfraConfigEnum.MAILER_SMTP_URL, InfraConfigEnum.MAILER_ADDRESS_FROM], 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,
],
};
}
/** /**
* Load environment variables from the database and set them in the process * Load environment variables from the database and set them in the process
@ -64,10 +69,9 @@ const AuthProviderConfigurations = {
export async function loadInfraConfiguration() { export async function loadInfraConfiguration() {
try { try {
const prisma = new PrismaService(); const prisma = new PrismaService();
const infraConfigs = await prisma.infraConfig.findMany(); const infraConfigs = await prisma.infraConfig.findMany();
const environmentObject: Record<string, any> = {}; const environmentObject: Record<string, string> = {};
infraConfigs.forEach((infraConfig) => { infraConfigs.forEach((infraConfig) => {
if (infraConfig.isEncrypted) { if (infraConfig.isEncrypted) {
environmentObject[infraConfig.name] = decrypt(infraConfig.value); environmentObject[infraConfig.name] = decrypt(infraConfig.value);
@ -83,6 +87,7 @@ export async function loadInfraConfiguration() {
// Prisma throw error if 'Can't reach at database server' OR 'Table does not exist' // Prisma throw error if 'Can't reach at database server' OR 'Table does not exist'
// Reason for not throwing error is, we want successful build during 'postinstall' and generate dist files // Reason for not throwing error is, we want successful build during 'postinstall' and generate dist files
console.log('Error from loadInfraConfiguration', error);
return { INFRA: {} }; return { INFRA: {} };
} }
} }
@ -95,176 +100,206 @@ export async function getDefaultInfraConfigs(): Promise<DefaultInfraConfig[]> {
const prisma = new PrismaService(); const prisma = new PrismaService();
// 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 configuredSSOProviders = getConfiguredSSOProvidersFromEnvFile(); const onboardingCompleteStatus = await isOnboardingCompleted();
const generatedAnalyticsUserId = generateAnalyticsUserId(); const generatedAnalyticsUserId = generateAnalyticsUserId();
const isSecureCookies = determineAllowSecureCookies(
process.env.VITE_BASE_URL,
);
const infraConfigDefaultObjs: DefaultInfraConfig[] = [ const infraConfigDefaultObjs: DefaultInfraConfig[] = [
{
name: InfraConfigEnum.ONBOARDING_COMPLETED,
value: onboardingCompleteStatus.toString(),
isEncrypted: false,
},
{
name: InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN,
value: null,
isEncrypted: false,
},
{
name: InfraConfigEnum.JWT_SECRET,
value: encrypt(randomBytes(32).toString('hex')),
isEncrypted: true,
},
{
name: InfraConfigEnum.SESSION_SECRET,
value: encrypt(randomBytes(32).toString('hex')),
isEncrypted: true,
},
{
name: InfraConfigEnum.TOKEN_SALT_COMPLEXITY,
value: '10',
isEncrypted: false,
},
{
name: InfraConfigEnum.MAGIC_LINK_TOKEN_VALIDITY,
value: '24', // 24 hours
isEncrypted: false,
},
{
name: InfraConfigEnum.REFRESH_TOKEN_VALIDITY,
value: '604800000', // 7 days in milliseconds
isEncrypted: false,
},
{
name: InfraConfigEnum.ACCESS_TOKEN_VALIDITY,
value: '86400000', // 1 day in milliseconds
isEncrypted: false,
},
{
name: InfraConfigEnum.ALLOW_SECURE_COOKIES,
value: isSecureCookies.toString(),
isEncrypted: false,
},
{
name: InfraConfigEnum.RATE_LIMIT_TTL,
value: '10000', // in milliseconds (10 seconds)
isEncrypted: false,
},
{
name: InfraConfigEnum.RATE_LIMIT_MAX,
value: '100', // 100 requests per IP per RATE_LIMIT_TTL
isEncrypted: false,
},
{ {
name: InfraConfigEnum.MAILER_SMTP_ENABLE, name: InfraConfigEnum.MAILER_SMTP_ENABLE,
value: process.env.MAILER_SMTP_ENABLE ?? 'true', value: 'false',
lastSyncedEnvFileValue: process.env.MAILER_SMTP_ENABLE ?? 'true',
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS, name: InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS,
value: process.env.MAILER_USE_CUSTOM_CONFIGS ?? 'false', value: null,
lastSyncedEnvFileValue: process.env.MAILER_USE_CUSTOM_CONFIGS ?? 'false',
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MAILER_SMTP_URL, name: InfraConfigEnum.MAILER_SMTP_URL,
value: encrypt(process.env.MAILER_SMTP_URL), value: null,
lastSyncedEnvFileValue: encrypt(process.env.MAILER_SMTP_URL),
isEncrypted: true, isEncrypted: true,
}, },
{ {
name: InfraConfigEnum.MAILER_ADDRESS_FROM, name: InfraConfigEnum.MAILER_ADDRESS_FROM,
value: process.env.MAILER_ADDRESS_FROM, value: null,
lastSyncedEnvFileValue: process.env.MAILER_ADDRESS_FROM,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MAILER_SMTP_HOST, name: InfraConfigEnum.MAILER_SMTP_HOST,
value: process.env.MAILER_SMTP_HOST, value: null,
lastSyncedEnvFileValue: process.env.MAILER_SMTP_HOST,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MAILER_SMTP_PORT, name: InfraConfigEnum.MAILER_SMTP_PORT,
value: process.env.MAILER_SMTP_PORT, value: null,
lastSyncedEnvFileValue: process.env.MAILER_SMTP_PORT,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MAILER_SMTP_SECURE, name: InfraConfigEnum.MAILER_SMTP_SECURE,
value: process.env.MAILER_SMTP_SECURE, value: null,
lastSyncedEnvFileValue: process.env.MAILER_SMTP_SECURE,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MAILER_SMTP_USER, name: InfraConfigEnum.MAILER_SMTP_USER,
value: process.env.MAILER_SMTP_USER, value: null,
lastSyncedEnvFileValue: process.env.MAILER_SMTP_USER,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MAILER_SMTP_PASSWORD, name: InfraConfigEnum.MAILER_SMTP_PASSWORD,
value: encrypt(process.env.MAILER_SMTP_PASSWORD), value: null,
lastSyncedEnvFileValue: encrypt(process.env.MAILER_SMTP_PASSWORD),
isEncrypted: true, isEncrypted: true,
}, },
{ {
name: InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED, name: InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
value: process.env.MAILER_TLS_REJECT_UNAUTHORIZED, value: null,
lastSyncedEnvFileValue: process.env.MAILER_TLS_REJECT_UNAUTHORIZED,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.GOOGLE_CLIENT_ID, name: InfraConfigEnum.GOOGLE_CLIENT_ID,
value: encrypt(process.env.GOOGLE_CLIENT_ID), value: null,
lastSyncedEnvFileValue: encrypt(process.env.GOOGLE_CLIENT_ID),
isEncrypted: true, isEncrypted: true,
}, },
{ {
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET, name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
value: encrypt(process.env.GOOGLE_CLIENT_SECRET), value: null,
lastSyncedEnvFileValue: encrypt(process.env.GOOGLE_CLIENT_SECRET),
isEncrypted: true, isEncrypted: true,
}, },
{ {
name: InfraConfigEnum.GOOGLE_CALLBACK_URL, name: InfraConfigEnum.GOOGLE_CALLBACK_URL,
value: process.env.GOOGLE_CALLBACK_URL, value: null,
lastSyncedEnvFileValue: process.env.GOOGLE_CALLBACK_URL,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.GOOGLE_SCOPE, name: InfraConfigEnum.GOOGLE_SCOPE,
value: process.env.GOOGLE_SCOPE, value: null,
lastSyncedEnvFileValue: process.env.GOOGLE_SCOPE,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.GITHUB_CLIENT_ID, name: InfraConfigEnum.GITHUB_CLIENT_ID,
value: encrypt(process.env.GITHUB_CLIENT_ID), value: null,
lastSyncedEnvFileValue: encrypt(process.env.GITHUB_CLIENT_ID),
isEncrypted: true, isEncrypted: true,
}, },
{ {
name: InfraConfigEnum.GITHUB_CLIENT_SECRET, name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
value: encrypt(process.env.GITHUB_CLIENT_SECRET), value: null,
lastSyncedEnvFileValue: encrypt(process.env.GITHUB_CLIENT_SECRET),
isEncrypted: true, isEncrypted: true,
}, },
{ {
name: InfraConfigEnum.GITHUB_CALLBACK_URL, name: InfraConfigEnum.GITHUB_CALLBACK_URL,
value: process.env.GITHUB_CALLBACK_URL, value: null,
lastSyncedEnvFileValue: process.env.GITHUB_CALLBACK_URL,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.GITHUB_SCOPE, name: InfraConfigEnum.GITHUB_SCOPE,
value: process.env.GITHUB_SCOPE, value: null,
lastSyncedEnvFileValue: process.env.GITHUB_SCOPE,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MICROSOFT_CLIENT_ID, name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
value: encrypt(process.env.MICROSOFT_CLIENT_ID), value: null,
lastSyncedEnvFileValue: encrypt(process.env.MICROSOFT_CLIENT_ID),
isEncrypted: true, isEncrypted: true,
}, },
{ {
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET, name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
value: encrypt(process.env.MICROSOFT_CLIENT_SECRET), value: null,
lastSyncedEnvFileValue: encrypt(process.env.MICROSOFT_CLIENT_SECRET),
isEncrypted: true, isEncrypted: true,
}, },
{ {
name: InfraConfigEnum.MICROSOFT_CALLBACK_URL, name: InfraConfigEnum.MICROSOFT_CALLBACK_URL,
value: process.env.MICROSOFT_CALLBACK_URL, value: null,
lastSyncedEnvFileValue: process.env.MICROSOFT_CALLBACK_URL,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MICROSOFT_SCOPE, name: InfraConfigEnum.MICROSOFT_SCOPE,
value: process.env.MICROSOFT_SCOPE, value: null,
lastSyncedEnvFileValue: process.env.MICROSOFT_SCOPE,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.MICROSOFT_TENANT, name: InfraConfigEnum.MICROSOFT_TENANT,
value: process.env.MICROSOFT_TENANT, value: null,
lastSyncedEnvFileValue: process.env.MICROSOFT_TENANT,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS, name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
value: configuredSSOProviders, value: null,
lastSyncedEnvFileValue: configuredSSOProviders,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION, name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
value: false.toString(), value: false.toString(),
lastSyncedEnvFileValue: null,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.ANALYTICS_USER_ID, name: InfraConfigEnum.ANALYTICS_USER_ID,
value: generatedAnalyticsUserId, value: generatedAnalyticsUserId,
lastSyncedEnvFileValue: null,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP, name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false', value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false',
lastSyncedEnvFileValue: null,
isEncrypted: false, isEncrypted: false,
}, },
{ {
name: InfraConfigEnum.USER_HISTORY_STORE_ENABLED, name: InfraConfigEnum.USER_HISTORY_STORE_ENABLED,
value: 'true', value: 'true',
lastSyncedEnvFileValue: null,
isEncrypted: false, isEncrypted: false,
}, },
]; ];
@ -311,48 +346,6 @@ export async function getEncryptionRequiredInfraConfigEntries(
return requiredEncryption; return requiredEncryption;
} }
/**
* Sync the 'infra_config' table with .env file
* @returns Array of InfraConfig
*/
export async function syncInfraConfigWithEnvFile() {
const prisma = new PrismaService();
const dbInfraConfigs = await prisma.infraConfig.findMany();
const updateRequiredObjs: (Partial<InfraConfig> & { id: string })[] = [];
for (const dbConfig of dbInfraConfigs) {
const envValue = process.env[dbConfig.name];
// lastSyncedEnvFileValue null check for backward compatibility from 2024.10.2 and below
if (!dbConfig.lastSyncedEnvFileValue && envValue) {
const configValue = dbConfig.isEncrypted ? encrypt(envValue) : envValue;
updateRequiredObjs.push({
id: dbConfig.id,
value: dbConfig.value === null ? configValue : undefined,
lastSyncedEnvFileValue: configValue,
});
continue;
}
// If the value in the database is different from the value in the .env file, means the value in the .env file has been updated
const rawLastSyncedEnvFileValue = dbConfig.isEncrypted
? decrypt(dbConfig.lastSyncedEnvFileValue)
: dbConfig.lastSyncedEnvFileValue;
if (rawLastSyncedEnvFileValue != envValue) {
const configValue = dbConfig.isEncrypted ? encrypt(envValue) : envValue;
updateRequiredObjs.push({
id: dbConfig.id,
value: configValue ?? null,
lastSyncedEnvFileValue: configValue ?? null,
});
}
}
return updateRequiredObjs;
}
/** /**
* Verify if 'infra_config' table is loaded with all entries * Verify if 'infra_config' table is loaded with all entries
* @returns boolean * @returns boolean
@ -389,72 +382,31 @@ export function stopApp() {
}, 5000); }, 5000);
} }
/**
* Get the configured SSO providers from .env file
* @description This function verify if the required parameters for each SSO provider are configured in .env file. Usage on first time setup and reset.
* @returns Array of configured SSO providers
*/
export function getConfiguredSSOProvidersFromEnvFile() {
const allowedAuthProviders: string[] =
process.env.VITE_ALLOWED_AUTH_PROVIDERS.split(',');
const configuredAuthProviders: string[] = [];
const addProviderIfConfigured = (provider) => {
const configParameters: string[] = AuthProviderConfigurations[provider];
const isConfigured = configParameters.every((configParameter) => {
return process.env[configParameter];
});
if (isConfigured) configuredAuthProviders.push(provider);
};
allowedAuthProviders.forEach((provider) => addProviderIfConfigured(provider));
if (configuredAuthProviders.length === 0) {
throwErr(AUTH_PROVIDER_NOT_CONFIGURED);
} else if (allowedAuthProviders.length !== configuredAuthProviders.length) {
const unConfiguredAuthProviders = allowedAuthProviders.filter(
(provider) => {
return !configuredAuthProviders.includes(provider);
},
);
console.log(
`${unConfiguredAuthProviders.join(
',',
)} SSO auth provider(s) are not configured properly in .env file. Do configure them from Admin Dashboard.`,
);
}
return configuredAuthProviders.join(',');
}
/** /**
* Get the configured SSO providers from 'infra_config' table. * Get the configured SSO providers from 'infra_config' table.
* @description Usage every time the app starts by AuthModule to initiate Strategies. * @description Usage every time the app starts by AuthModule to initiate Strategies.
* @returns Array of configured SSO providers * @returns Array of configured SSO providers
*/ */
export async function getConfiguredSSOProvidersFromInfraConfig() { export async function getConfiguredSSOProvidersFromInfraConfig() {
const prisma = new PrismaService();
const env = await loadInfraConfiguration(); const env = await loadInfraConfiguration();
const providerConfigKeys = getAuthProviderRequiredKeys(env);
const allowedAuthProviders: string[] = const allowedAuthProviders: string[] =
env['INFRA'].VITE_ALLOWED_AUTH_PROVIDERS.split(','); env['INFRA'].VITE_ALLOWED_AUTH_PROVIDERS?.split(',') ?? [];
const configuredAuthProviders: string[] = [];
const addProviderIfConfigured = (provider) => { const configuredAuthProviders = allowedAuthProviders.filter((provider) => {
const configParameters: string[] = AuthProviderConfigurations[provider]; const requiredKeys = providerConfigKeys[provider];
return requiredKeys?.every((key) => env['INFRA'][key]);
const isConfigured = configParameters.every((configParameter) => { });
return env['INFRA'][configParameter];
});
if (isConfigured) configuredAuthProviders.push(provider);
};
allowedAuthProviders.forEach((provider) => addProviderIfConfigured(provider));
if (configuredAuthProviders.length === 0) { if (configuredAuthProviders.length === 0) {
await prisma.infraConfig.update({
where: { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS },
data: { value: null },
});
return ''; return '';
} else if (allowedAuthProviders.length !== configuredAuthProviders.length) { } else if (allowedAuthProviders.length !== configuredAuthProviders.length) {
const prisma = new PrismaService();
await prisma.infraConfig.update({ await prisma.infraConfig.update({
where: { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS }, where: { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS },
data: { value: configuredAuthProviders.join(',') }, data: { value: configuredAuthProviders.join(',') },
@ -468,6 +420,24 @@ export async function getConfiguredSSOProvidersFromInfraConfig() {
return configuredAuthProviders.join(','); return configuredAuthProviders.join(',');
} }
/**
* Check if the onboarding is completed by verifying if the allowed auth providers are configured
* @returns boolean
*/
export async function isOnboardingCompleted(): Promise<boolean> {
const prisma = new PrismaService();
const allowedProviders = await prisma.infraConfig.findUnique({
where: { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS },
select: { value: true },
});
if (!allowedProviders?.value || allowedProviders.value === '') {
return false;
}
return true;
}
/** /**
* Generate a hashed valued for analytics * Generate a hashed valued for analytics
* @returns Generated hashed value * @returns Generated hashed value
@ -476,3 +446,58 @@ export function generateAnalyticsUserId() {
const hashedUserID = randomBytes(20).toString('hex'); const hashedUserID = randomBytes(20).toString('hex');
return hashedUserID; return hashedUserID;
} }
/**
* Determine if ALLOW_SECURE_COOKIES should be true or false
* @returns boolean
*/
export function determineAllowSecureCookies(appBaseUrl: string) {
return appBaseUrl.startsWith('https');
}
/**
* Builds a map of environment variables that are derived from other configuration values
* @returns Record<string, string>
*/
export async function buildDerivedEnv() {
const envConfigMap = await loadInfraConfiguration();
const derivedEnv: Record<string, string> = {};
// Normalize URLs
const baseUrl = process.env.VITE_BASE_URL || '';
const backendUrl = process.env.VITE_BACKEND_API_URL || '';
const normalizedBackendUrl = backendUrl?.replace(/\/+$/, ''); // remove trailing slash
// Set ALLOW_SECURE_COOKIES based on base URL protocol
const expectedSecure = determineAllowSecureCookies(baseUrl).toString();
const currentSecure = envConfigMap.INFRA.ALLOW_SECURE_COOKIES;
if (currentSecure !== expectedSecure) {
derivedEnv.ALLOW_SECURE_COOKIES = expectedSecure;
}
// Set GOOGLE_CALLBACK_URL, MICROSOFT_CALLBACK_URL, and GITHUB_CALLBACK_URL based on backend URL (self healing) if user changed the backend URL
// Callback URL definitions
const callbackConfigs = [
{ key: InfraConfigEnum.GOOGLE_CALLBACK_URL, path: '/auth/google/callback' },
{
key: InfraConfigEnum.MICROSOFT_CALLBACK_URL,
path: '/auth/microsoft/callback',
},
{ key: InfraConfigEnum.GITHUB_CALLBACK_URL, path: '/auth/github/callback' },
];
// Update callback URLs if they don't match the backend
for (const { key, path } of callbackConfigs) {
const currentCallback = envConfigMap.INFRA[key];
const expectedCallback = `${normalizedBackendUrl}${path}`;
if (
backendUrl.length > 0 &&
currentCallback &&
currentCallback !== expectedCallback
) {
derivedEnv[key] = expectedCallback;
}
}
return derivedEnv;
}

View file

@ -2,10 +2,13 @@ import { Module } from '@nestjs/common';
import { InfraConfigService } from './infra-config.service'; import { InfraConfigService } from './infra-config.service';
import { SiteController } from './infra-config.controller'; import { SiteController } from './infra-config.controller';
import { InfraConfigResolver } from './infra-config.resolver'; import { InfraConfigResolver } from './infra-config.resolver';
import { UserModule } from 'src/user/user.module';
import { OnboardingController } from './onboarding.controller';
@Module({ @Module({
imports: [UserModule],
controllers: [SiteController, OnboardingController],
providers: [InfraConfigResolver, InfraConfigService], providers: [InfraConfigResolver, InfraConfigService],
exports: [InfraConfigService], exports: [InfraConfigService],
controllers: [SiteController],
}) })
export class InfraConfigModule {} export class InfraConfigModule {}

View file

@ -14,15 +14,18 @@ import { InfraConfig } from './infra-config.model';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { ServiceStatus } from './helper'; import { ServiceStatus } from './helper';
import * as E from 'fp-ts/Either'; import * as E from 'fp-ts/Either';
import { UserService } from 'src/user/user.service';
const mockPrisma = mockDeep<PrismaService>(); const mockPrisma = mockDeep<PrismaService>();
const mockConfigService = mockDeep<ConfigService>(); const mockConfigService = mockDeep<ConfigService>();
const mockPubsub = mockDeep<PubSubService>(); const mockPubsub = mockDeep<PubSubService>();
const mockUserService = mockDeep<UserService>();
const infraConfigService = new InfraConfigService( const infraConfigService = new InfraConfigService(
mockPrisma, mockPrisma,
mockConfigService, mockConfigService,
mockPubsub, mockPubsub,
mockUserService,
); );
const INITIALIZED_DATE_CONST = new Date(); const INITIALIZED_DATE_CONST = new Date();

View file

@ -25,15 +25,23 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { import {
ServiceStatus, ServiceStatus,
buildDerivedEnv,
getDefaultInfraConfigs, getDefaultInfraConfigs,
getEncryptionRequiredInfraConfigEntries, getEncryptionRequiredInfraConfigEntries,
getMissingInfraConfigEntries, getMissingInfraConfigEntries,
stopApp, stopApp,
syncInfraConfigWithEnvFile,
} from './helper'; } from './helper';
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args'; import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
import { AuthProvider } from 'src/auth/helper'; import { AuthProvider } from 'src/auth/helper';
import { PubSubService } from 'src/pubsub/pubsub.service'; import { PubSubService } from 'src/pubsub/pubsub.service';
import { UserService } from 'src/user/user.service';
import {
GetOnboardingConfigResponse,
GetOnboardingStatusResponse,
SaveOnboardingConfigRequest,
SaveOnboardingConfigResponse,
} from './dto/onboarding.dto';
import * as crypto from 'crypto';
@Injectable() @Injectable()
export class InfraConfigService implements OnModuleInit { export class InfraConfigService implements OnModuleInit {
@ -41,6 +49,7 @@ export class InfraConfigService implements OnModuleInit {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly pubsub: PubSubService, private readonly pubsub: PubSubService,
private readonly userService: UserService,
) {} ) {}
// Following fields are not updatable by `infraConfigs` Mutation. Use dedicated mutations for these fields instead. // Following fields are not updatable by `infraConfigs` Mutation. Use dedicated mutations for these fields instead.
@ -94,14 +103,14 @@ export class InfraConfigService implements OnModuleInit {
await Promise.allSettled(dbOperations); await Promise.allSettled(dbOperations);
} }
// Sync the InfraConfigs with the .env file, if .env file updates later on // Derive env variables programmatically if they don't exist or need to be updated
const envFileChangesRequired = await syncInfraConfigWithEnvFile(); const derivedEnv = await buildDerivedEnv();
if (envFileChangesRequired.length > 0) {
const dbOperations = envFileChangesRequired.map((dbConfig) => { if (Object.keys(derivedEnv).length > 0) {
const { id, ...dataObj } = dbConfig; const dbOperations = Object.entries(derivedEnv).map(([name, value]) => {
return this.prisma.infraConfig.update({ return this.prisma.infraConfig.update({
where: { id: dbConfig.id }, where: { name: name as InfraConfigEnum },
data: dataObj, data: { value },
}); });
}); });
await Promise.allSettled(dbOperations); await Promise.allSettled(dbOperations);
@ -111,7 +120,7 @@ export class InfraConfigService implements OnModuleInit {
if ( if (
propsToInsert.length > 0 || propsToInsert.length > 0 ||
encryptionRequiredEntries.length > 0 || encryptionRequiredEntries.length > 0 ||
envFileChangesRequired.length > 0 Object.keys(derivedEnv).length > 0
) { ) {
stopApp(); stopApp();
} }
@ -210,10 +219,16 @@ export class InfraConfigService implements OnModuleInit {
* @param infraConfigs InfraConfigs to update * @param infraConfigs InfraConfigs to update
* @returns InfraConfig model * @returns InfraConfig model
*/ */
async updateMany(infraConfigs: InfraConfigArgs[]) { async updateMany(
for (let i = 0; i < infraConfigs.length; i++) { infraConfigs: InfraConfigArgs[],
if (this.EXCLUDE_FROM_UPDATE_CONFIGS.includes(infraConfigs[i].name)) checkDisallowedKeys: boolean = true,
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED); ) {
if (checkDisallowedKeys) {
// Check if the names are allowed to update by client
for (let i = 0; i < infraConfigs.length; i++) {
if (this.EXCLUDE_FROM_UPDATE_CONFIGS.includes(infraConfigs[i].name))
return E.left(INFRA_CONFIG_OPERATION_NOT_ALLOWED);
}
} }
const isValidate = this.validateEnvValues(infraConfigs); const isValidate = this.validateEnvValues(infraConfigs);
@ -374,7 +389,7 @@ export class InfraConfigService implements OnModuleInit {
const infra = await this.get(InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS); const infra = await this.get(InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS);
if (E.isLeft(infra)) return E.left(infra.left); if (E.isLeft(infra)) return E.left(infra.left);
const allowedAuthProviders = infra.right.value.split(','); const allowedAuthProviders = infra.right.value?.split(',') ?? [];
let updatedAuthProviders = allowedAuthProviders; let updatedAuthProviders = allowedAuthProviders;
const infraConfigMap = await this.getInfraConfigsMap(); const infraConfigMap = await this.getInfraConfigsMap();
@ -457,9 +472,11 @@ export class InfraConfigService implements OnModuleInit {
* @returns string[] * @returns string[]
*/ */
getAllowedAuthProviders() { getAllowedAuthProviders() {
return this.configService return (
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS') this.configService
.split(','); .get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
?.split(',') ?? []
);
} }
/** /**
@ -485,6 +502,107 @@ export class InfraConfigService implements OnModuleInit {
return E.right(infraConfig.right); return E.right(infraConfig.right);
} }
/**
* Get onboarding status
* @returns GetOnboardingStatusResponse
*/
async getOnboardingStatus() {
const configMap = await this.getInfraConfigsMap();
const usersCount = await this.userService.getUsersCount();
return E.right({
onboardingCompleted: configMap.ONBOARDING_COMPLETED === 'true',
canReRunOnboarding: usersCount === 0,
} as GetOnboardingStatusResponse);
}
/**
* Update the onboarding configuration
* @param dto SaveOnboardingConfigRequest
*/
async updateOnboardingConfig(dto: SaveOnboardingConfigRequest) {
const onboardingRecoveryToken = crypto.randomUUID();
const configEntries: InfraConfigArgs[] = [
...Object.entries(dto).map(([key, value]) => ({
name: key as InfraConfigEnum,
value,
})),
{
name: InfraConfigEnum.ONBOARDING_COMPLETED,
value: 'true',
},
{
name: InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN,
value: onboardingRecoveryToken,
},
];
const isValidated = this.validateEnvValues(configEntries);
if (E.isLeft(isValidated)) return E.left(isValidated.left);
// Verify MAILER_SMTP_ENABLE
if (
dto[InfraConfigEnum.MAILER_SMTP_ENABLE] === 'true' &&
!this.isServiceConfigured(
AuthProvider.EMAIL,
dto as unknown as Record<string, string>,
)
) {
return E.left(INFRA_CONFIG_SERVICE_NOT_CONFIGURED);
}
// Verify VITE_ALLOWED_AUTH_PROVIDERS
const allowedAuthProviders =
dto[InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS].split(',');
if (allowedAuthProviders.length === 0) {
return E.left(AUTH_PROVIDER_NOT_SPECIFIED);
}
for (const provider of allowedAuthProviders) {
if (
!Object.values(AuthProvider).includes(provider as AuthProvider) ||
!this.isServiceConfigured(
provider as AuthProvider,
dto as unknown as Record<string, string>,
)
) {
return E.left(INFRA_CONFIG_SERVICE_NOT_CONFIGURED);
}
}
// Move forward with updating the InfraConfigs
const isUpdated = await this.updateMany(configEntries, false);
if (E.isLeft(isUpdated)) return E.left(isUpdated.left);
return E.right({
token: onboardingRecoveryToken,
} as SaveOnboardingConfigResponse);
}
/**
* Get onboarding configuration
* @param token Onboarding recovery token
* @returns GetOnboardingConfigResponse
*/
async getOnboardingConfig(token: string) {
const configs = await this.getMany(Object.values(InfraConfigEnum), false);
if (E.isLeft(configs)) return E.left(configs.left);
// Check if the onboarding recovery token is valid
const recoveryToken = configs.right.find(
(config) => config.name === InfraConfigEnum.ONBOARDING_RECOVERY_TOKEN,
)?.value;
const tokenIsValid = token === recoveryToken;
const onboardingConfig = configs.right.reduce((acc, config) => {
acc[config.name] = tokenIsValid ? config.value : null;
return acc;
}, {} as GetOnboardingConfigResponse);
return E.right(onboardingConfig);
}
/** /**
* Reset all the InfraConfigs to their default values (from .env) * Reset all the InfraConfigs to their default values (from .env)
*/ */
@ -530,98 +648,75 @@ export class InfraConfigService implements OnModuleInit {
value: string; value: string;
}[], }[],
) { ) {
for (let i = 0; i < infraConfigs.length; i++) { for (const config of infraConfigs) {
switch (infraConfigs[i].name) { const { name, value } = config;
const fail = () => {
console.error(`[Infra Validation Failed] Key: ${name}`);
return E.left(INFRA_CONFIG_INVALID_INPUT);
};
switch (name) {
case InfraConfigEnum.MAILER_SMTP_ENABLE: 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: 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);
break;
case InfraConfigEnum.MAILER_ADDRESS_FROM:
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: 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: case InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED:
if ( if (value !== 'true' && value !== 'false') return fail();
infraConfigs[i].value !== 'true' &&
infraConfigs[i].value !== 'false'
)
return E.left(INFRA_CONFIG_INVALID_INPUT);
break; break;
case InfraConfigEnum.MAILER_SMTP_URL:
if (!validateSMTPUrl(value)) return fail();
break;
case InfraConfigEnum.MAILER_ADDRESS_FROM:
if (!validateSMTPEmail(value)) return fail();
break;
case InfraConfigEnum.MAILER_SMTP_HOST:
case InfraConfigEnum.MAILER_SMTP_PORT:
case InfraConfigEnum.MAILER_SMTP_USER:
case InfraConfigEnum.MAILER_SMTP_PASSWORD:
case InfraConfigEnum.GOOGLE_CLIENT_ID: case InfraConfigEnum.GOOGLE_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_CLIENT_SECRET: case InfraConfigEnum.GOOGLE_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GOOGLE_SCOPE: case InfraConfigEnum.GOOGLE_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_CLIENT_ID: case InfraConfigEnum.GITHUB_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_CLIENT_SECRET: case InfraConfigEnum.GITHUB_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.GITHUB_SCOPE: case InfraConfigEnum.GITHUB_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_ID: case InfraConfigEnum.MICROSOFT_CLIENT_ID:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CLIENT_SECRET: case InfraConfigEnum.MICROSOFT_CLIENT_SECRET:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_CALLBACK_URL:
if (!validateUrl(infraConfigs[i].value))
return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_SCOPE: case InfraConfigEnum.MICROSOFT_SCOPE:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
break;
case InfraConfigEnum.MICROSOFT_TENANT: case InfraConfigEnum.MICROSOFT_TENANT:
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT); if (!value) return fail();
break; break;
case InfraConfigEnum.GOOGLE_CALLBACK_URL:
case InfraConfigEnum.GITHUB_CALLBACK_URL:
case InfraConfigEnum.MICROSOFT_CALLBACK_URL:
if (!validateUrl(value)) return fail();
break;
case InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS:
const allowedAuthProviders = value.split(',');
if (
allowedAuthProviders.length === 0 ||
allowedAuthProviders.some(
(p) => !Object.values(AuthProvider).includes(p as AuthProvider),
)
) {
return fail();
}
break;
case InfraConfigEnum.TOKEN_SALT_COMPLEXITY:
case InfraConfigEnum.MAGIC_LINK_TOKEN_VALIDITY:
case InfraConfigEnum.ACCESS_TOKEN_VALIDITY:
case InfraConfigEnum.REFRESH_TOKEN_VALIDITY:
case InfraConfigEnum.RATE_LIMIT_TTL:
case InfraConfigEnum.RATE_LIMIT_MAX:
if (!Number.isInteger(Number(value)) || Number(value) < 1)
return fail();
break;
default: default:
break; break;
} }

View file

@ -0,0 +1,96 @@
import { Body, Controller, Get, HttpStatus, Post, Query } from '@nestjs/common';
import { InfraConfigService } from './infra-config.service';
import { RESTError } from 'src/types/RESTError';
import { throwHTTPErr } from 'src/utils';
import * as E from 'fp-ts/Either';
import {
GetOnboardingConfigResponse,
GetOnboardingStatusResponse,
SaveOnboardingConfigRequest,
SaveOnboardingConfigResponse,
} from './dto/onboarding.dto';
import { ApiCreatedResponse, ApiOkResponse } from '@nestjs/swagger';
import { plainToInstance } from 'class-transformer';
@Controller({ path: 'onboarding', version: '1' })
export class OnboardingController {
constructor(private infraConfigService: InfraConfigService) {}
@Get('status')
@ApiOkResponse({
description: 'Get onboarding status',
type: GetOnboardingStatusResponse,
})
async getOnboardingStatus(): Promise<GetOnboardingStatusResponse> {
const onboardingStatus =
await this.infraConfigService.getOnboardingStatus();
if (E.isLeft(onboardingStatus))
throwHTTPErr(<RESTError>{
message: onboardingStatus.left,
statusCode: HttpStatus.UNPROCESSABLE_ENTITY,
});
return plainToInstance(
GetOnboardingStatusResponse,
{
onboardingCompleted: onboardingStatus.right.onboardingCompleted,
canReRunOnboarding: onboardingStatus.right.canReRunOnboarding,
},
{
excludeExtraneousValues: true,
enableImplicitConversion: true,
},
);
}
@Post('config')
@ApiCreatedResponse({
description: 'Onboarding configuration updated successfully',
type: SaveOnboardingConfigResponse,
})
async updateOnboardingConfig(@Body() dto: SaveOnboardingConfigRequest) {
const updateConfigResult =
await this.infraConfigService.updateOnboardingConfig(dto);
if (E.isLeft(updateConfigResult))
throwHTTPErr(<RESTError>{
message: updateConfigResult.left,
statusCode: HttpStatus.BAD_REQUEST,
});
return plainToInstance(
SaveOnboardingConfigResponse,
updateConfigResult.right,
{
excludeExtraneousValues: true,
enableImplicitConversion: true,
},
);
}
@Get('config')
@ApiOkResponse({
description: 'Get onboarding configuration',
type: GetOnboardingConfigResponse,
})
async getOnboardingConfig(@Query('token') token: string) {
const onboardingConfig =
await this.infraConfigService.getOnboardingConfig(token);
if (E.isLeft(onboardingConfig))
throwHTTPErr(<RESTError>{
message: onboardingConfig.left,
statusCode: HttpStatus.BAD_REQUEST,
});
return plainToInstance(
GetOnboardingConfigResponse,
onboardingConfig.right,
{
excludeExtraneousValues: true,
enableImplicitConversion: true,
},
);
}
}

View file

@ -10,49 +10,33 @@ function isSMTPCustomConfigsEnabled(value) {
return value === 'true'; return value === 'true';
} }
export function getMailerAddressFrom(env, config): string { export function getMailerAddressFrom(env): string {
return ( return env.INFRA.MAILER_ADDRESS_FROM ?? throwErr(MAILER_SMTP_URL_UNDEFINED);
env.INFRA.MAILER_ADDRESS_FROM ??
config.get('MAILER_ADDRESS_FROM') ??
throwErr(MAILER_SMTP_URL_UNDEFINED)
);
} }
export function getTransportOption(env, config): TransportType { export function getTransportOption(env): TransportType {
const useCustomConfigs = isSMTPCustomConfigsEnabled( const useCustomConfigs = isSMTPCustomConfigsEnabled(
env.INFRA.MAILER_USE_CUSTOM_CONFIGS ?? env.INFRA.MAILER_USE_CUSTOM_CONFIGS,
config.get('MAILER_USE_CUSTOM_CONFIGS'),
); );
if (!useCustomConfigs) { if (!useCustomConfigs) {
console.log('Using simple mailer configuration'); console.log('Using simple mailer configuration');
return ( return env.INFRA.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED);
env.INFRA.MAILER_SMTP_URL ??
config.get('MAILER_SMTP_URL') ??
throwErr(MAILER_SMTP_URL_UNDEFINED)
);
} else { } else {
console.log('Using advanced mailer configuration'); console.log('Using advanced mailer configuration');
return { return {
host: env.INFRA.MAILER_SMTP_HOST ?? config.get('MAILER_SMTP_HOST'), host: env.INFRA.MAILER_SMTP_HOST,
port: +(env.INFRA.MAILER_SMTP_PORT ?? config.get('MAILER_SMTP_PORT')), port: +env.INFRA.MAILER_SMTP_PORT,
secure: secure: env.INFRA.MAILER_SMTP_SECURE === 'true',
(env.INFRA.MAILER_SMTP_SECURE ?? config.get('MAILER_SMTP_SECURE')) ===
'true',
auth: { auth: {
user: user:
env.INFRA.MAILER_SMTP_USER ?? env.INFRA.MAILER_SMTP_USER ?? throwErr(MAILER_SMTP_USER_UNDEFINED),
config.get('MAILER_SMTP_USER') ??
throwErr(MAILER_SMTP_USER_UNDEFINED),
pass: pass:
env.INFRA.MAILER_SMTP_PASSWORD ?? env.INFRA.MAILER_SMTP_PASSWORD ??
config.get('MAILER_SMTP_PASSWORD') ??
throwErr(MAILER_SMTP_PASSWORD_UNDEFINED), throwErr(MAILER_SMTP_PASSWORD_UNDEFINED),
}, },
tls: { tls: {
rejectUnauthorized: rejectUnauthorized: env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED === 'true',
(env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED ??
config.get('MAILER_TLS_REJECT_UNAUTHORIZED')) === 'true',
}, },
}; };
} }

View file

@ -2,7 +2,6 @@ import { Global, Module } from '@nestjs/common';
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer'; import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'; import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { MailerService } from './mailer.service'; import { MailerService } from './mailer.service';
import { ConfigService } from '@nestjs/config';
import { loadInfraConfiguration } from 'src/infra-config/helper'; import { loadInfraConfiguration } from 'src/infra-config/helper';
import { getMailerAddressFrom, getTransportOption } from './helper'; import { getMailerAddressFrom, getTransportOption } from './helper';
@ -14,7 +13,6 @@ import { getMailerAddressFrom, getTransportOption } from './helper';
}) })
export class MailerModule { export class MailerModule {
static async register() { static async register() {
const config = new ConfigService();
const env = await loadInfraConfiguration(); const env = await loadInfraConfiguration();
// If mailer SMTP is DISABLED, return the module without any configuration (service, listener, etc.) // If mailer SMTP is DISABLED, return the module without any configuration (service, listener, etc.)
@ -28,9 +26,9 @@ export class MailerModule {
// If mailer is ENABLED, return the module with configuration (service, etc.) // If mailer is ENABLED, return the module with configuration (service, etc.)
// Determine transport configuration based on custom config flag // Determine transport configuration based on custom config flag
const transportOption = getTransportOption(env, config); const transportOption = getTransportOption(env);
// Get mailer address from environment or config // Get mailer address from environment or config
const mailerAddressFrom = getMailerAddressFrom(env, config); const mailerAddressFrom = getMailerAddressFrom(env);
return { return {
module: MailerModule, module: MailerModule,

View file

@ -2,16 +2,16 @@ import { NestFactory } from '@nestjs/core';
import { json } from 'express'; import { json } from 'express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import * as cookieParser from 'cookie-parser'; import * as cookieParser from 'cookie-parser';
import * as crypto from 'crypto';
import { ValidationPipe, VersioningType } from '@nestjs/common'; import { ValidationPipe, VersioningType } from '@nestjs/common';
import * as session from 'express-session'; import * as session from 'express-session';
import { emitGQLSchemaFile } from './gql-schema'; import { emitGQLSchemaFile } from './gql-schema';
import { checkEnvironmentAuthProvider } from './utils'; import * as crypto from 'crypto';
import * as morgan from 'morgan';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { InfraTokenModule } from './infra-token/infra-token.module'; import { InfraTokenModule } from './infra-token/infra-token.module';
function setupSwagger(app) { function setupSwagger(app, isProduction: boolean) {
const swaggerDocPath = '/api-docs'; const swaggerDocPath = '/api-docs';
const config = new DocumentBuilder() const config = new DocumentBuilder()
@ -30,7 +30,7 @@ function setupSwagger(app) {
.build(); .build();
const document = SwaggerModule.createDocument(app, config, { const document = SwaggerModule.createDocument(app, config, {
include: [InfraTokenModule], include: isProduction ? [InfraTokenModule] : [],
}); });
SwaggerModule.setup(swaggerDocPath, app, document, { SwaggerModule.setup(swaggerDocPath, app, document, {
swaggerOptions: { persistAuthorization: true, ignoreGlobalPrefix: true }, swaggerOptions: { persistAuthorization: true, ignoreGlobalPrefix: true },
@ -41,15 +41,11 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
const isProduction = configService.get('PRODUCTION') === 'true';
console.log(`Running in production: ${configService.get('PRODUCTION')}`); console.log(`Running in production: ${isProduction}`);
console.log(`Port: ${configService.get('PORT')}`); console.log(`Port: ${configService.get('PORT')}`);
checkEnvironmentAuthProvider(
configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS') ??
configService.get('VITE_ALLOWED_AUTH_PROVIDERS'),
);
app.use( app.use(
session({ session({
secret: secret:
@ -72,7 +68,7 @@ async function bootstrap() {
}), }),
); );
if (configService.get('PRODUCTION') === 'true') { if (isProduction) {
console.log('Enabling CORS with production settings'); console.log('Enabling CORS with production settings');
app.enableCors({ app.enableCors({
origin: configService.get('WHITELISTED_ORIGINS').split(','), origin: configService.get('WHITELISTED_ORIGINS').split(','),
@ -95,8 +91,9 @@ async function bootstrap() {
transform: true, transform: true,
}), }),
); );
app.use(morgan(':method :url :status - :response-time ms'));
await setupSwagger(app); await setupSwagger(app, isProduction);
await app.listen(configService.get('PORT') || 3170); await app.listen(configService.get('PORT') || 3170);

View file

@ -910,7 +910,7 @@ describe('deleteUserFromAllTeams', () => {
const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid)(); const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid)();
await expect(result).rejects.toThrowError(TEAM_ONLY_ONE_OWNER); await expect(result).rejects.toThrow(TEAM_ONLY_ONE_OWNER);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
where: { where: {
userUid: dbTeamMember.userUid, userUid: dbTeamMember.userUid,
@ -932,7 +932,7 @@ describe('deleteUserFromAllTeams', () => {
const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid); const result = teamService.deleteUserFromAllTeams(dbTeamMember.userUid);
await expect(result).rejects.toThrowError(TEAM_INVALID_ID_OR_USER); await expect(result).rejects.toThrow(TEAM_INVALID_ID_OR_USER);
expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({ expect(mockPrisma.teamMember.findMany).toHaveBeenCalledWith({
where: { where: {
userUid: dbTeamMember.userUid, userUid: dbTeamMember.userUid,

View file

@ -1,4 +1,18 @@
export enum InfraConfigEnum { export enum InfraConfigEnum {
ONBOARDING_COMPLETED = 'ONBOARDING_COMPLETED',
ONBOARDING_RECOVERY_TOKEN = 'ONBOARDING_RECOVERY_TOKEN',
JWT_SECRET = 'JWT_SECRET',
SESSION_SECRET = 'SESSION_SECRET',
TOKEN_SALT_COMPLEXITY = 'TOKEN_SALT_COMPLEXITY',
MAGIC_LINK_TOKEN_VALIDITY = 'MAGIC_LINK_TOKEN_VALIDITY',
REFRESH_TOKEN_VALIDITY = 'REFRESH_TOKEN_VALIDITY',
ACCESS_TOKEN_VALIDITY = 'ACCESS_TOKEN_VALIDITY',
ALLOW_SECURE_COOKIES = 'ALLOW_SECURE_COOKIES',
RATE_LIMIT_TTL = 'RATE_LIMIT_TTL',
RATE_LIMIT_MAX = 'RATE_LIMIT_MAX',
MAILER_SMTP_ENABLE = 'MAILER_SMTP_ENABLE', MAILER_SMTP_ENABLE = 'MAILER_SMTP_ENABLE',
MAILER_USE_CUSTOM_CONFIGS = 'MAILER_USE_CUSTOM_CONFIGS', MAILER_USE_CUSTOM_CONFIGS = 'MAILER_USE_CUSTOM_CONFIGS',
MAILER_SMTP_URL = 'MAILER_SMTP_URL', MAILER_SMTP_URL = 'MAILER_SMTP_URL',

View file

@ -83,7 +83,7 @@ describe('UserSettingsService', () => {
await userSettingsService.createUserSettings(user, settings.properties); await userSettingsService.createUserSettings(user, settings.properties);
expect(mockPubSub.publish).toBeCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`user_settings/${user.uid}/created`, `user_settings/${user.uid}/created`,
settings, settings,
); );
@ -126,7 +126,7 @@ describe('UserSettingsService', () => {
await userSettingsService.updateUserSettings(user, settings.properties); await userSettingsService.updateUserSettings(user, settings.properties);
expect(mockPubSub.publish).toBeCalledWith( expect(mockPubSub.publish).toHaveBeenCalledWith(
`user_settings/${user.uid}/updated`, `user_settings/${user.uid}/updated`,
settings, settings,
); );

View file

@ -8,14 +8,7 @@ import { pipe } from 'fp-ts/lib/function';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import * as T from 'fp-ts/Task'; import * as T from 'fp-ts/Task';
import * as TE from 'fp-ts/TaskEither'; import * as TE from 'fp-ts/TaskEither';
import { AuthProvider } from './auth/helper'; import { ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY, JSON_INVALID } from './errors';
import {
ENV_EMPTY_AUTH_PROVIDERS,
ENV_NOT_FOUND_KEY_AUTH_PROVIDERS,
ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY,
ENV_NOT_SUPPORT_AUTH_PROVIDERS,
JSON_INVALID,
} from './errors';
import { TeamAccessRole } from './team/team.model'; import { TeamAccessRole } from './team/team.model';
import { RESTError } from './types/RESTError'; import { RESTError } from './types/RESTError';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
@ -234,36 +227,6 @@ export function isValidLength(title: string, length: number) {
return true; return true;
} }
/**
* This function is called by bootstrap() in main.ts
* It checks if the "VITE_ALLOWED_AUTH_PROVIDERS" environment variable is properly set or not.
* If not, it throws an error.
*/
export function checkEnvironmentAuthProvider(
VITE_ALLOWED_AUTH_PROVIDERS: string,
) {
if (!VITE_ALLOWED_AUTH_PROVIDERS) {
throw new Error(ENV_NOT_FOUND_KEY_AUTH_PROVIDERS);
}
if (VITE_ALLOWED_AUTH_PROVIDERS === '') {
throw new Error(ENV_EMPTY_AUTH_PROVIDERS);
}
const givenAuthProviders = VITE_ALLOWED_AUTH_PROVIDERS.split(',').map(
(provider) => provider.toLocaleUpperCase(),
);
const supportedAuthProviders = Object.values(AuthProvider).map(
(provider: string) => provider.toLocaleUpperCase(),
);
for (const givenAuthProvider of givenAuthProviders) {
if (!supportedAuthProviders.includes(givenAuthProvider)) {
throw new Error(ENV_NOT_SUPPORT_AUTH_PROVIDERS);
}
}
}
/** /**
* Adds escape backslashes to the input so that it can be used inside * Adds escape backslashes to the input so that it can be used inside
* SQL LIKE/ILIKE queries. Inspired by PHP's `mysql_real_escape_string` * SQL LIKE/ILIKE queries. Inspired by PHP's `mysql_real_escape_string`
@ -342,7 +305,7 @@ const ENCRYPTION_ALGORITHM = 'aes-256-cbc';
export function encrypt(text: string, key = process.env.DATA_ENCRYPTION_KEY) { export function encrypt(text: string, key = process.env.DATA_ENCRYPTION_KEY) {
if (!key) throw new Error(ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY); if (!key) throw new Error(ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY);
if (text === null || text === undefined) return text; if (!text || text === '') return text;
const iv = crypto.randomBytes(16); const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv( const cipher = crypto.createCipheriv(
@ -367,7 +330,7 @@ export function decrypt(
) { ) {
if (!key) throw new Error(ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY); if (!key) throw new Error(ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY);
if (encryptedData === null || encryptedData === undefined) { if (!encryptedData || encryptedData === '') {
return encryptedData; return encryptedData;
} }

View file

@ -1332,7 +1332,7 @@
"admin_success": "User is now an admin!!", "admin_success": "User is now an admin!!",
"and": "and", "and": "and",
"clear_selection": "Clear Selection", "clear_selection": "Clear Selection",
"configure_auth": "Check out the documentation to configure auth providers.", "configure_auth": "Please set up an auth provider from the admin settings or check out the documentation to configure auth providers.",
"confirm_admin_to_user": "Do you want to remove admin status from this user?", "confirm_admin_to_user": "Do you want to remove admin status from this user?",
"confirm_admins_to_users": "Do you want to remove admin status from selected users?", "confirm_admins_to_users": "Do you want to remove admin status from selected users?",
"confirm_delete_infra_token": "Are you sure you want to delete the infra token {tokenLabel}?", "confirm_delete_infra_token": "Are you sure you want to delete the infra token {tokenLabel}?",

View file

@ -56,6 +56,27 @@
:label="`${t('auth.send_magic_link')}`" :label="`${t('auth.send_magic_link')}`"
/> />
</form> </form>
<div
v-if="!allowedAuthProviders?.length"
class="flex flex-col items-center text-center"
>
<p>{{ t("state.require_auth_provider") }}</p>
<p>{{ t("state.configure_auth") }}</p>
<div class="mt-5">
<a
href="https://docs.hoppscotch.io/documentation/self-host/getting-started"
>
<HoppButtonSecondary
outline
filled
blank
:icon="IconFileText"
:label="t('state.self_host_docs')"
/>
</a>
</div>
</div>
<div v-if="mode === 'email-sent'" class="flex flex-col px-4"> <div v-if="mode === 'email-sent'" class="flex flex-col px-4">
<div class="flex max-w-md flex-col items-center justify-center"> <div class="flex max-w-md flex-col items-center justify-center">
<icon-lucide-inbox class="h-6 w-6 text-accent" /> <icon-lucide-inbox class="h-6 w-6 text-accent" />
@ -133,6 +154,7 @@ import IconGithub from "~icons/auth/github"
import IconGoogle from "~icons/auth/google" import IconGoogle from "~icons/auth/google"
import IconMicrosoft from "~icons/auth/microsoft" import IconMicrosoft from "~icons/auth/microsoft"
import IconArrowLeft from "~icons/lucide/arrow-left" import IconArrowLeft from "~icons/lucide/arrow-left"
import IconFileText from "~icons/lucide/file-text"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { LoginItemDef } from "~/platform/auth" import { LoginItemDef } from "~/platform/auth"

View file

@ -42,9 +42,21 @@
"sso": "SSO", "sso": "SSO",
"tenant": "TENANT", "tenant": "TENANT",
"title": "OAuth Providers", "title": "OAuth Providers",
"token": {
"description": "Configure token for your server",
"title": "Token",
"jwt_secret": " JWT Secret",
"token_salt_complexity": "Token Salt Complexity",
"magic_link_token_validity": "Magic Link Token Validity (in hour)",
"refresh_token_validity": "Refresh Token Validity (in milliseconds)",
"access_token_validity": "Access Token Validity (in milliseconds)",
"session_secret": "Session Secret",
"update_failure": "Failed to update token configurations!!"
},
"update_failure": "Failed to update authentication provider configurations!!" "update_failure": "Failed to update authentication provider configurations!!"
}, },
"confirm_changes": "Hoppscotch server must restart to reflect the new changes. Confirm changes made to the server configurations?", "confirm_changes": "Hoppscotch server must restart to reflect the new changes. Confirm changes made to the server configurations?",
"invalid_number": "Please enter a valid number for the field",
"input_empty": "Please fill all the fields before updating the configurations", "input_empty": "Please fill all the fields before updating the configurations",
"input_validation_error": "Some fields have invalid values. Please correct them before updating the configurations", "input_validation_error": "Some fields have invalid values. Please correct them before updating the configurations",
"data_sharing": { "data_sharing": {
@ -85,6 +97,14 @@
"toggle_failure": "Failed to toggle history!!", "toggle_failure": "Failed to toggle history!!",
"clear_confirm": "Are you sure you want to clear all history?" "clear_confirm": "Are you sure you want to clear all history?"
}, },
"rate_limit": {
"description": "Configure rate limiting for your Hoppscotch instance",
"rate_limit_max": "Maximum Requests",
"rate_limit_ttl": "Time to Leave (in seconds)",
"title": "Rate Limit Configurations",
"update_failure": "Failed to update rate limit configurations!!",
"input_validation_error": "Please enter valid values for rate limit configurations"
},
"reset": { "reset": {
"confirm_reset": "Hoppscotch server must restart to reflect the new changes. Confirm the reset of server configurations?", "confirm_reset": "Hoppscotch server must restart to reflect the new changes. Confirm the reset of server configurations?",
"description": "Default configurations will be loaded as specified in the environment file", "description": "Default configurations will be loaded as specified in the environment file",
@ -102,6 +122,7 @@
"auth": "Authentication", "auth": "Authentication",
"activity": "Activity", "activity": "Activity",
"infra_tokens": "Infra Tokens", "infra_tokens": "Infra Tokens",
"rate_limit": "Rate Limit",
"smtp": "SMTP", "smtp": "SMTP",
"miscellaneous": "Miscellaneous", "miscellaneous": "Miscellaneous",
"reset": "Reset Configurations" "reset": "Reset Configurations"

View file

@ -17,7 +17,7 @@
<div v-else class="flex flex-1 flex-col"> <div v-else class="flex flex-1 flex-col">
<div <div
class="p-6 bg-primaryLight rounded-lg border border-primaryDark shadow" class="p-4 bg-primaryLight rounded-lg border border-primaryDark shadow"
> >
<div <div
v-if="mode === 'sign-in' && allowedAuthProviders" v-if="mode === 'sign-in' && allowedAuthProviders"
@ -71,7 +71,10 @@
:label="t('state.send_magic_link')" :label="t('state.send_magic_link')"
/> />
</form> </form>
<div v-if="!allowedAuthProviders"> <div
v-if="!allowedAuthProviders?.length"
class="flex flex-col items-center text-center"
>
<p>{{ t('state.require_auth_provider') }}</p> <p>{{ t('state.require_auth_provider') }}</p>
<p>{{ t('state.configure_auth') }}</p> <p>{{ t('state.configure_auth') }}</p>
<div class="mt-5"> <div class="mt-5">
@ -102,7 +105,16 @@
</div> </div>
</div> </div>
<section class="mt-16"> <div v-if="canReRunOnboarding" class="mt-4">
<span class="text-tiny"> Need to re-configure auth providers? </span>
<HoppSmartAnchor
class="link text-tiny"
:to="`/onboarding`"
label="Setup Authentication"
/>
</div>
<section class="mt-8">
<div <div
v-if=" v-if="
mode === 'sign-in' && mode === 'sign-in' &&
@ -151,6 +163,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computedAsync } from '@vueuse/core';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from '~/composables/i18n'; import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast'; import { useToast } from '~/composables/toast';
@ -183,6 +196,15 @@ const nonAdminUser = ref(false);
const allowedAuthProviders = ref<string[]>([]); const allowedAuthProviders = ref<string[]>([]);
// check if the user can re-run onboarding
const canReRunOnboarding = computedAsync(async () => {
const onboardingStatus = await auth.getOnboardingStatus();
return (
onboardingStatus?.onboardingCompleted && onboardingStatus.canReRunOnboarding
);
});
onMounted(async () => { onMounted(async () => {
const user = auth.getCurrentUser(); const user = auth.getCurrentUser();
if (user && !user.isAdmin) { if (user && !user.isAdmin) {

View file

@ -0,0 +1,304 @@
<template>
<div class="flex flex-col flex-1 py-8 p-4 justify-center w-full">
<div class="w-full max-w-screen-md mx-auto">
<div
v-if="isFirstTimeSetup && authConfigStep === 1"
class="py-8 rounded max-h-lg overflow-y-auto"
>
<div>
<h1 class="text-[1rem] font-normal">
Please select the authentication methods you want to set up.
</h1>
</div>
<div>
<div class="flex items-center space-x-4 my-8">
<div
class="flex flex-col space-y-2 p-5 h-40 cursor-pointer flex-1 border-2 bg-primaryLight rounded-lg border-primaryDark shadow hover:border-accent transition"
:class="{
'!bg-primary !border-accentDark':
selectedOptions.includes('OAuth'),
}"
@click="toggleSelectedOption('OAuth')"
>
<span class="text-[0.9rem] text-secondaryDark"> OAuth </span>
<span class="text-secondaryLight h-10">
Set up OAuth providers like Google, GitHub, Microsoft, etc.
</span>
<div class="my-4">
<div class="flex items-center -space-x-2">
<img
alt="user 1"
src="/assets/icons/auth/google.svg"
class="relative inline-block h-6 w-6 rounded-full border-2 border-primary object-cover object-center hover:z-10 focus:z-10"
/>
<img
alt="user 2"
src="/assets/icons/auth/github.svg"
class="relative inline-block h-6 w-6 rounded-full border-2 border-primary object-cover object-center hover:z-10 focus:z-10"
/>
<img
alt="user 3"
src="/assets/icons/auth/microsoft.svg"
class="relative inline-block h-6 w-6 rounded-full border-2 border-primary object-cover object-center hover:z-10 focus:z-10"
/>
</div>
</div>
</div>
<div
class="flex flex-col space-y-2 p-5 h-40 cursor-pointer flex-1 border-2 bg-primaryLight rounded-lg border-primaryDark shadow hover:border-accent transition"
:class="{
'!bg-primary !border-accentDark':
selectedOptions.includes('SMTP'),
}"
@click="toggleSelectedOption('SMTP')"
>
<span class="text-[0.9rem] text-secondaryDark"> SMTP </span>
<span class="text-secondaryLight h-10">
Set up SMTP for email authentication.
</span>
<div class="my-4">
<div class="flex items-center -space-x-2">
<img
alt="user 2"
src="/assets/icons/auth/email.svg"
class="relative inline-block h-6 w-6 rounded-full border-2 border-primary object-cover object-center hover:z-10 focus:z-10"
/>
</div>
</div>
</div>
</div>
<HoppButtonPrimary
:label="'Add Auth Configs'"
@click="addAuthConfig"
:icon="IconLucideArrowRight"
:reverse="true"
class="mt-6"
/>
</div>
</div>
<!-- Auth setup step 2 -->
<div v-else-if="authConfigStep === 2">
<button
v-if="isFirstTimeSetup"
class="items-center flex space-x-2 cursor-pointer mb-4 group"
@click="authConfigStep = 1"
>
<span class="group-hover:opacity-80 transition-opacity">
<IconLucideArrowLeft class="svg-icons" />
</span>
<span class="group-hover:opacity-80 transition-opacity">Back</span>
</button>
<h2>
Please add the configurations for the selected authentication methods.
</h2>
<div class="my-5 overflow-y-auto max-h-[60vh]">
<div
id="accordion-nested-parent"
data-accordion="collapse"
class="space-y-4"
>
<UiAccordion
v-if="selectedOptions.includes('OAuth')"
:initial-open="isOAuthEnabled"
class="bg-primary rounded-lg border-primaryDark shadow p-4 border flex flex-col"
>
<template v-slot:header="{ isOpen, toggleAccordion }">
<div class="w-full">
<div class="flex items-center justify-between flex-1 mb-2">
<span class="font-semibold text-[0.8rem]">OAuth </span>
<span>
<HoppSmartToggle
:on="isOpen"
@change="
() => {
toggleAccordion();
toggleConfig('OAUTH');
}
"
class="ml-2"
/>
</span>
</div>
<span class="text-tiny text-secondaryLight">
Select the OAuth providers you want to enable and provide
<br />
the necessary configurations.
</span>
</div>
</template>
<template v-slot:content>
<OAuthSetup
v-model:currentConfigs="currentConfigs"
v-model:enabledConfigs="enabledConfigs"
@toggleConfig="toggleConfig"
/>
</template>
</UiAccordion>
<UiAccordion
v-if="selectedOptions.includes('SMTP')"
:initial-open="enabledConfigs.includes('MAILER')"
class="bg-primary rounded-lg border-primaryDark shadow p-4 border flex flex-col"
>
<template v-slot:header="{ isOpen, toggleAccordion }">
<div class="w-full">
<div class="flex items-center justify-between flex-1 mb-2">
<span class="font-semibold text-[0.8rem]">SMTP</span>
<span>
<HoppSmartToggle
:on="isOpen"
@change="
() => {
toggleAccordion();
toggleConfig('EMAIL');
toggleSmtpConfig();
}
"
class="ml-2"
/>
</span>
</div>
<p class="text-secondaryLight text-tiny">
Configure the SMTP settings for sending emails.
</p>
</div>
</template>
<template v-slot:content>
<SmtpSetup
v-model:currentConfigs="currentConfigs"
v-model:enabledConfigs="enabledConfigs"
/>
</template>
</UiAccordion>
</div>
</div>
<HoppButtonPrimary
:label="'Save Auth Configs'"
@click="addOnboardingConfigs"
:reverse="true"
:icon="IconLucideSave"
class="mt-4"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import {
OAuthProvider,
OnBoardingSummary,
useOnboardingConfigHandler,
} from '~/composables/useOnboardingConfigHandler';
import OAuthSetup from './OAuthSetup.vue';
import SmtpSetup from './SmtpSetup.vue';
import IconLucideArrowRight from '~icons/lucide/arrow-right';
import IconLucideArrowLeft from '~icons/lucide/arrow-left';
import IconLucideSave from '~icons/lucide/save';
import { useToast } from '~/composables/toast';
const toast = useToast();
const props = withDefaults(
defineProps<{
isFirstTimeSetup: boolean;
}>(),
{
isFirstTimeSetup: true,
}
);
const emit = defineEmits<{
(
e: 'complete-onboarding',
payload: {
submittingConfigs: boolean;
summary: OnBoardingSummary;
}
): void;
}>();
const authConfigStep = ref(1);
const selectedOptions = ref<Array<'OAuth' | 'SMTP' | ''>>([]);
watch(
() => props.isFirstTimeSetup,
(newValue) => {
if (!newValue) {
authConfigStep.value = 2; // Skip to step 2 if not first time setup
selectedOptions.value = ['OAuth', 'SMTP']; // Default to both options
} else {
authConfigStep.value = 1; // Reset to step 1 for first time setup
selectedOptions.value = []; // Reset selected options
}
},
{ immediate: true }
);
const {
currentConfigs,
enabledConfigs,
isProvidersLoading,
submittingConfigs,
onBoardingSummary,
addOnBoardingConfigs,
toggleConfig,
toggleSmtpConfig,
} = useOnboardingConfigHandler();
const OAuthProviders: OAuthProvider[] = ['GOOGLE', 'GITHUB', 'MICROSOFT'];
const isOAuthEnabled = ref(false);
onMounted(() => {
// Check if any OAuth provider is enabled
isOAuthEnabled.value = OAuthProviders.some((provider) =>
enabledConfigs.value.includes(provider)
);
});
watch(
isProvidersLoading,
(isLoading) => {
if (!isLoading) {
isOAuthEnabled.value = OAuthProviders.some((provider) =>
enabledConfigs.value.includes(provider)
);
}
},
{ immediate: true }
);
const addOnboardingConfigs = async () => {
const res = await addOnBoardingConfigs();
if (res && res.token) {
emit('complete-onboarding', {
submittingConfigs: submittingConfigs.value,
summary: onBoardingSummary.value,
});
}
};
const toggleSelectedOption = (option: 'OAuth' | 'SMTP') => {
if (selectedOptions.value.includes(option)) {
selectedOptions.value = selectedOptions.value.filter(
(opt) => opt !== option
);
} else {
selectedOptions.value.push(option);
}
};
const addAuthConfig = () => {
if (selectedOptions.value.length === 0) {
toast.error('Please select at least one authentication method.');
return;
}
authConfigStep.value = 2;
};
</script>

View file

@ -0,0 +1,112 @@
<template>
<div class="flex flex-col space-y-4 p-4 max-w-screen-md mx-auto w-full">
<template
v-if="
onBoardingSummary.type === 'success' &&
onBoardingSummary.configsAdded.length > 0
"
>
<h1 class="text-2xl font-bold text-white">Setup Complete 🎉</h1>
<h2>
You have successfully completed the onboarding process for your
Hoppscotch instance.
<br />
The page will automatically redirect to the dashboard in a few seconds
or you can reload once the server is restarted.
</h2>
<div
class="my-4 p-4 bg-primaryLight rounded-lg border border-primaryDark shadow"
>
<h3 class="text-lg mb-6">Configuration Summary</h3>
<div v-for="config in onBoardingSummary.configsAdded" class="my-2">
<div class="flex items-center space-x-2">
<icon-lucide-check class="svg-icons text-green-500" />
<p class="text-secondary">
<span class="capitalize"> {{ config.toLocaleLowerCase() }}</span>
authentication has been successfully configured.
</p>
</div>
</div>
</div>
<HoppButtonPrimary
v-if="duration"
:label="`Server is restarting in ${duration} seconds...`"
:disabled="duration > 0"
@click="emit('finish')"
class="w-min"
/>
<HoppButtonPrimary
v-else
label="Go to Dashboard"
@click="emit('finish')"
class="w-min"
/>
</template>
<template v-else>
<h1 class="text-2xl font-bold text-white">Onboarding Incomplete</h1>
<h2>
There was an issue completing the onboarding process. Please check the
configurations and try again.
</h2>
<HoppButtonPrimary
label="Retry Onboarding"
@click="emit('back')"
class="w-min"
/>
<p class="text-secondaryLight text-tiny mt-2">
If you need help, please refer to the
<a
href="https://docs.hoppscotch.io/documentation/self-host/community-edition"
target="_blank"
class="text-secondaryLight underline"
>documentation</a
>.
</p>
</template>
</div>
</template>
<script lang="ts" setup>
import { HoppButtonPrimary } from '@hoppscotch/ui';
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { OnBoardingSummary } from '~/composables/useOnboardingConfigHandler';
const router = useRouter();
const props = defineProps<{
onBoardingSummary: OnBoardingSummary;
submittingConfigs: boolean;
}>();
const emit = defineEmits<{
(e: 'back'): void;
(e: 'finish'): void;
}>();
const duration = ref(30);
const timer = ref<NodeJS.Timeout | null>(null);
const startCountdown = () => {
timer.value = setInterval(() => {
duration.value--;
if (duration.value === 0 && timer.value) {
clearInterval(timer.value);
// Redirect to dashboard after countdown
router.push({ name: 'index' });
}
}, 1000);
};
onMounted(() => {
if (props.onBoardingSummary.type === 'success') {
startCountdown();
}
});
onUnmounted(() => {
if (!timer.value) return;
clearInterval(timer.value);
});
</script>

View file

@ -0,0 +1,102 @@
<template>
<div class="space-y-2 flex flex-col my-4">
<UiAccordion
v-for="[provider, value] in Object.entries(currentConfigs.oAuthProviders)"
:initial-open="enabledConfigs.includes(provider as EnabledConfig)"
>
<template v-slot:header="{ isOpen, toggleAccordion }">
<div class="flex items-center justify-between flex-1">
<span class="font-semibold text-[.8rem] capitalize">{{
provider.toLocaleLowerCase()
}}</span>
<span>
<HoppSmartToggle
:on="isOpen"
@change="() => {
toggleAccordion()
emit('toggleConfig', (provider as EnabledConfig));
}"
/>
</span>
</div>
</template>
<template v-slot:content>
<div class="flex flex-col space-y-4 w-full flex-1 py-4">
<template v-for="(_, key) in value" :key="key">
<HoppSmartInput
v-if="isCallbackUrl(key as string)"
v-model="currentConfigs.oAuthProviders[provider as OAuthProvider][key]"
:label="(makeReadableKey(key as string,true))"
input-styles="floating-input !border-0"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1 rounded border border-divider"
:disabled="true"
>
<template
#button
v-if="currentConfigs.oAuthProviders[provider as OAuthProvider][key]"
>
<HoppSmartItem
v-tippy="{ theme: 'tooltip' }"
:icon="IconLucideCopy"
:title="'Copy to clipboard'"
@click="() => copyCallbackUrl(currentConfigs.oAuthProviders[provider as OAuthProvider][key])"
class="hover:bg-transparent"
/>
</template>
</HoppSmartInput>
<HoppSmartInput
v-else
v-model="currentConfigs.oAuthProviders[provider as OAuthProvider][key]"
:label="(makeReadableKey(key as string,true))"
input-styles="floating-input"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
/>
</template>
</div>
</template>
</UiAccordion>
</div>
</template>
<script lang="ts" setup>
import { HoppSmartItem } from '@hoppscotch/ui';
import { useVModel } from '@vueuse/core';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import {
Configs,
EnabledConfig,
OAuthProvider,
} from '~/composables/useOnboardingConfigHandler';
import { copyToClipboard } from '~/helpers/utils/clipboard';
import { makeReadableKey } from '~/helpers/utils/readableKey';
import IconLucideCopy from '~icons/lucide/copy';
const t = useI18n();
const toast = useToast();
const props = defineProps<{
currentConfigs: Configs;
enabledConfigs: EnabledConfig[];
}>();
const emit = defineEmits<{
(e: 'toggleConfig', provider: EnabledConfig): void;
}>();
const currentConfigs = useVModel(props, 'currentConfigs');
// check if the key is a callback URL
const isCallbackUrl = (key: string): boolean => {
return key.toLowerCase().includes('callback');
};
const copyCallbackUrl = (callbackURL: string): void => {
if (!callbackURL) return;
copyToClipboard(callbackURL);
toast.success(`${t('state.copied_to_clipboard')}`);
};
</script>

View file

@ -0,0 +1,160 @@
<template>
<div class="my-4">
<!-- Basic SMTP Fields -->
<div class="flex flex-col space-y-2">
<HoppSmartInput
v-model="currentConfigs.mailerConfigs[smtp.ADDRESS_FROM.id]"
:label="smtp.ADDRESS_FROM.text"
input-styles="floating-input"
class="!my-2 !bg-primaryLight flex-1"
/>
<HoppSmartInput
v-if="smtp.SMTP_URL.enabled"
v-model="currentConfigs.mailerConfigs[smtp.SMTP_URL.id]"
:label="smtp.SMTP_URL.text"
input-styles="floating-input"
class="!my-2 !bg-primaryLight flex-1"
/>
</div>
<!-- Custom Config Fields -->
<div v-if="smtp.USE_CUSTOM_CONFIGS.enabled" class="flex flex-col space-y-2">
<HoppSmartInput
v-for="key in ['SMTP_HOST', 'SMTP_PORT', 'SMTP_USER', 'SMTP_PASSWORD'] as AllMailerConfigKeys[]"
:key="key"
v-model="currentConfigs.mailerConfigs[smtp[key].id]"
:label="smtp[key].text"
input-styles="floating-input"
:type="key === 'SMTP_PASSWORD' ? 'password' : 'text'"
class="!my-2 !bg-primaryLight flex-1"
/>
<!-- Custom Config Checkboxes -->
<div class="flex items-center space-x-2">
<HoppSmartCheckbox
:on="smtp.SMTP_SECURE.enabled"
@change="toggleConfig('SMTP_SECURE')"
>
{{ smtp.SMTP_SECURE.text }}
</HoppSmartCheckbox>
<HoppSmartCheckbox
:on="smtp.TLS_REJECT_UNAUTHORIZED.enabled"
@change="toggleConfig('TLS_REJECT_UNAUTHORIZED')"
>
{{ smtp.TLS_REJECT_UNAUTHORIZED.text }}
</HoppSmartCheckbox>
</div>
</div>
<!-- Toggle: Use Custom Configs -->
<HoppSmartCheckbox
class="mt-4"
:on="smtp.USE_CUSTOM_CONFIGS.enabled"
@change="toggleConfig('USE_CUSTOM_CONFIGS')"
>
{{ smtp.USE_CUSTOM_CONFIGS.text }}
</HoppSmartCheckbox>
<p class="text-secondaryLight text-tiny mt-2">
Enable to configure your own SMTP credentials
</p>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useVModel } from '@vueuse/core';
import {
Configs,
EnabledConfig,
MailerConfigKeys,
} from '~/composables/useOnboardingConfigHandler';
const props = defineProps<{
currentConfigs: Configs;
enabledConfigs: EnabledConfig[];
}>();
const currentConfigs = useVModel(props, 'currentConfigs');
type ConfigKey = keyof Configs['mailerConfigs'];
type ConfigField = {
id: ConfigKey;
text: string;
value: string;
enabled: boolean;
};
type AllMailerConfigKeys = Exclude<MailerConfigKeys, 'SMTP_ENABLE'>;
type MailerConfig = Record<AllMailerConfigKeys, ConfigField>;
const smtp = computed<MailerConfig>(() => {
const cfg = currentConfigs.value.mailerConfigs;
const isCustom = cfg.MAILER_USE_CUSTOM_CONFIGS === 'true';
return {
SMTP_URL: {
id: 'MAILER_SMTP_URL',
text: 'SMTP URL',
value: cfg.MAILER_SMTP_URL,
enabled: !isCustom,
},
ADDRESS_FROM: {
id: 'MAILER_ADDRESS_FROM',
text: 'Address From',
value: cfg.MAILER_ADDRESS_FROM,
enabled: true,
},
USE_CUSTOM_CONFIGS: {
id: 'MAILER_USE_CUSTOM_CONFIGS',
text: 'Use Custom Configs',
value: cfg.MAILER_USE_CUSTOM_CONFIGS,
enabled: isCustom,
},
SMTP_SECURE: {
id: 'MAILER_SMTP_SECURE',
text: 'SMTP Secure',
value: cfg.MAILER_SMTP_SECURE,
enabled: isCustom && cfg.MAILER_SMTP_SECURE === 'true',
},
TLS_REJECT_UNAUTHORIZED: {
id: 'MAILER_TLS_REJECT_UNAUTHORIZED',
text: 'TLS Reject Unauthorized',
value: cfg.MAILER_TLS_REJECT_UNAUTHORIZED,
enabled: isCustom && cfg.MAILER_TLS_REJECT_UNAUTHORIZED === 'true',
},
SMTP_USER: {
id: 'MAILER_SMTP_USER',
text: 'SMTP User',
value: cfg.MAILER_SMTP_USER,
enabled: isCustom,
},
SMTP_PASSWORD: {
id: 'MAILER_SMTP_PASSWORD',
text: 'SMTP Password',
value: cfg.MAILER_SMTP_PASSWORD,
enabled: isCustom,
},
SMTP_HOST: {
id: 'MAILER_SMTP_HOST',
text: 'SMTP Host',
value: cfg.MAILER_SMTP_HOST,
enabled: isCustom,
},
SMTP_PORT: {
id: 'MAILER_SMTP_PORT',
text: 'SMTP Port',
value: cfg.MAILER_SMTP_PORT,
enabled: isCustom,
},
};
});
const toggleConfig = (key: AllMailerConfigKeys) => {
const id = smtp.value[key].id;
const current = currentConfigs.value.mailerConfigs[id];
currentConfigs.value.mailerConfigs[id] =
current === 'true' ? 'false' : 'true';
};
</script>

View file

@ -0,0 +1,18 @@
<template>
<div class="flex flex-1 justify-center items-center">
<div class="max-w-screen-md mx-auto p-8 flex flex-col space-y-4">
<div class="flex flex-col space-y-2 mb-4">
<img src="/logo.svg" alt="hoppscotch-logo" class="w-20 mb-4" />
<h1 class="text-3xl font-bold text-secondaryDark">Welcome</h1>
<h2 class="mt-3">
This is the onboarding process for setting up your Hoppscotch
instance.
</h2>
<p class="text-secondaryLight">
Please set up either SMTP or OAuth for authentication.
</p>
</div>
<HoppButtonPrimary label="Start Onboarding" @click="$emit('next')" />
</div>
</div>
</template>

View file

@ -61,6 +61,12 @@
</div> </div>
</div> </div>
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab id="token" :label="t('configs.auth_providers.token.title')">
<div class="pb-8 px-4">
<SettingsAuthToken v-model:config="workingConfigs" />
</div>
</HoppSmartTab>
</HoppSmartTabs> </HoppSmartTabs>
</div> </div>
</template> </template>
@ -82,7 +88,7 @@ const emit = defineEmits<{
}>(); }>();
// Auth Sub Tabs // Auth Sub Tabs
type AuthSubTabs = 'auth-providers' | 'email-auth'; type AuthSubTabs = 'auth-providers' | 'email-auth' | 'token';
const selectedAuthSubTab = ref<AuthSubTabs>('auth-providers'); const selectedAuthSubTab = ref<AuthSubTabs>('auth-providers');
const workingConfigs = useVModel(props, 'config', emit); const workingConfigs = useVModel(props, 'config', emit);

View file

@ -0,0 +1,205 @@
<template>
<div v-if="authTokenConfig" class="grid md:grid-cols-3 gap-4 md:gap-4 pt-8">
<div class="md:col-span-1">
<h3 class="heading">{{ t('configs.auth_providers.token.title') }}</h3>
<p class="my-1 text-secondaryLight">
{{ t('configs.auth_providers.token.description') }}
</p>
</div>
<div class="space-y-8 sm:px-8 md:col-span-2">
<section>
<div class="flex items-center justify-end">
<HoppButtonSecondary
blank
v-tippy="{ theme: 'tooltip', allowHTML: true }"
to="https://docs.hoppscotch.io/documentation/self-host/community-edition/prerequisites#email-delivery"
:title="t('support.documentation')"
:icon="IconHelpCircle"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
/>
</div>
<div class="space-y-4 pt-10 pb-4">
<div class="">
<div class="max-w-xs flex flex-col space-y-4">
<div class="flex flex-col space-y-2">
<label>{{
t('configs.auth_providers.token.jwt_secret')
}}</label>
<HoppSmartInput
v-model="authTokenConfig.fields.jwt_secret"
placeholder="e.g., your-secret-key"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
input-styles="!border-0 "
:type="isMasked('jwt_secret') ? 'password' : 'text'"
>
<template #button>
<HoppButtonSecondary
:icon="isMasked('jwt_secret') ? IconEye : IconEyeOff"
class="bg-primaryLight rounded"
@click="toggleMask('jwt_secret')"
/>
</template>
</HoppSmartInput>
</div>
<div class="flex flex-col space-y-2">
<label>{{
t('configs.auth_providers.token.token_salt_complexity')
}}</label>
<HoppSmartInput
v-model="authTokenConfig.fields.token_salt_complexity"
placeholder="e.g., 10 (salt complexity)"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
type="number"
@update:model-value="
validateNumberValue(
authTokenConfig.fields.token_salt_complexity
)
"
/>
</div>
<div class="flex flex-col space-y-2">
<label>{{
t('configs.auth_providers.token.magic_link_token_validity')
}}</label>
<HoppSmartInput
v-model="authTokenConfig.fields.magic_link_token_validity"
placeholder="e.g., 3 (in hour)"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
type="number"
@update:model-value="
validateNumberValue(
authTokenConfig.fields.magic_link_token_validity
)
"
/>
</div>
<div class="flex flex-col space-y-2">
<label>{{
t('configs.auth_providers.token.refresh_token_validity')
}}</label>
<HoppSmartInput
v-model="authTokenConfig.fields.refresh_token_validity"
placeholder="e.g., 604800000 (in milliseconds)"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
type="number"
@update:model-value="
validateNumberValue(
authTokenConfig.fields.refresh_token_validity
)
"
/>
</div>
<div class="flex flex-col space-y-2">
<label>{{
t('configs.auth_providers.token.access_token_validity')
}}</label>
<HoppSmartInput
v-model="authTokenConfig.fields.access_token_validity"
placeholder="e.g., 86400000 (in milliseconds)"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
type="number"
@update:model-value="
validateNumberValue(
authTokenConfig.fields.access_token_validity
)
"
/>
</div>
<div class="flex flex-col space-y-2">
<label>{{
t('configs.auth_providers.token.session_secret')
}}</label>
<HoppSmartInput
v-model="authTokenConfig.fields.session_secret"
placeholder="e.g., your-session-secret"
:autofocus="false"
input-styles="!border-0 "
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
:type="isMasked('session_secret') ? 'password' : 'text'"
>
<template #button>
<HoppButtonSecondary
:icon="isMasked('session_secret') ? IconEye : IconEyeOff"
class="bg-primaryLight rounded"
@click="toggleMask('session_secret')"
/>
</template>
</HoppSmartInput>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core';
import { computed, ref } from 'vue';
import { useI18n } from '~/composables/i18n';
import { ServerConfigs } from '~/helpers/configs';
import IconHelpCircle from '~icons/lucide/help-circle';
import IconEye from '~icons/lucide/eye';
import IconEyeOff from '~icons/lucide/eye-off';
import { useToast } from '~/composables/toast';
const t = useI18n();
const toast = useToast();
const props = defineProps<{
config: ServerConfigs;
}>();
const emit = defineEmits<{
(e: 'update:config', v: ServerConfigs): void;
}>();
const workingConfigs = useVModel(props, 'config', emit);
// Get or set token from workingConfigs
const authTokenConfig = computed({
get: () => workingConfigs.value?.tokenConfigs,
set: (value) => (workingConfigs.value.tokenConfigs = value),
});
const maskState = ref<Record<string, boolean>>({
jwt_secret: true,
session_secret: true,
});
const toggleMask = (fieldKey: string) => {
maskState.value[fieldKey] = !maskState.value[fieldKey];
};
const isMasked = (fieldKey: string) => maskState.value[fieldKey];
const validateNumberValue = (value: string | number) => {
const num = typeof value === 'string' ? parseInt(value, 10) : value;
if (isNaN(num) || num <= 0) {
toast.error(t('configs.invalid_number'));
}
};
</script>
<style lang="scss">
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View file

@ -43,7 +43,7 @@
<template <template
v-if="field.applicableProviders.includes(provider.name)" v-if="field.applicableProviders.includes(provider.name)"
> >
<label>{{ field.name }}</label> <label>{{ makeReadableKey(field.name, true) }}</label>
<span class="flex max-w-lg"> <span class="flex max-w-lg">
<HoppSmartInput <HoppSmartInput
v-model="provider.fields[field.key as keyof typeof provider['fields']]" v-model="provider.fields[field.key as keyof typeof provider['fields']]"
@ -51,15 +51,21 @@
isMasked(provider.name, field.key) ? 'password' : 'text' isMasked(provider.name, field.key) ? 'password' : 'text'
" "
:autofocus="false" :autofocus="false"
class="!my-2 !bg-primaryLight flex-1" class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
/> input-styles="!border-0"
<HoppButtonSecondary >
:icon=" <template #button>
isMasked(provider.name, field.key) ? IconEye : IconEyeOff <HoppButtonSecondary
" :icon="
class="bg-primaryLight h-9 mt-2" isMasked(provider.name, field.key)
@click="toggleMask(provider.name, field.key)" ? IconEye
/> : IconEyeOff
"
class="bg-primaryLight rounded"
@click="toggleMask(provider.name, field.key)"
/>
</template>
</HoppSmartInput>
</span> </span>
</template> </template>
</div> </div>
@ -75,6 +81,7 @@ import { useVModel } from '@vueuse/core';
import { reactive } from 'vue'; import { reactive } from 'vue';
import { useI18n } from '~/composables/i18n'; import { useI18n } from '~/composables/i18n';
import { ServerConfigs, SsoAuthProviders } from '~/helpers/configs'; import { ServerConfigs, SsoAuthProviders } from '~/helpers/configs';
import { makeReadableKey } from '~/helpers/utils/readableKey';
import IconCircleHelp from '~icons/lucide/circle-help'; import IconCircleHelp from '~icons/lucide/circle-help';
import IconEye from '~icons/lucide/eye'; import IconEye from '~icons/lucide/eye';
import IconEyeOff from '~icons/lucide/eye-off'; import IconEyeOff from '~icons/lucide/eye-off';

View file

@ -0,0 +1,111 @@
<template>
<div v-if="rateLimitConfig" class="grid md:grid-cols-3 gap-4 md:gap-4 pt-8">
<div class="md:col-span-1 px-4">
<h3 class="heading">{{ t('configs.rate_limit.title') }}</h3>
<p class="my-1 text-secondaryLight">
{{ t('configs.rate_limit.description') }}
</p>
</div>
<div class="space-y-8 sm:px-8 md:col-span-2">
<section>
<div class="flex items-center justify-between">
<h4 class="font-semibold text-secondaryDark">
{{ t('configs.rate_limit.title') }}
</h4>
<HoppButtonSecondary
blank
v-tippy="{ theme: 'tooltip', allowHTML: true }"
to="https://docs.hoppscotch.io/documentation/self-host/community-edition/prerequisites#email-delivery"
:title="t('support.documentation')"
:icon="IconHelpCircle"
class="rounded hover:bg-primaryDark focus-visible:bg-primaryDark"
/>
</div>
<div class="space-y-4 py-4">
<div class="">
<div class="max-w-xs flex flex-col space-y-4">
<div class="flex flex-col space-y-2">
<label>{{ t('configs.rate_limit.rate_limit_ttl') }}</label>
<HoppSmartInput
v-model="rateLimitConfig.fields.rate_limit_ttl"
placeholder="e.g., 60 (in seconds)"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
type="number"
@update:model-value="
validateNumberValue(rateLimitConfig.fields.rate_limit_ttl)
"
/>
</div>
<div class="flex flex-col space-y-2">
<label>{{ t('configs.rate_limit.rate_limit_max') }}</label>
<HoppSmartInput
v-model="rateLimitConfig.fields.rate_limit_max"
placeholder="e.g., 100 (requests per TTL)"
:autofocus="false"
class="!my-2 !bg-primaryLight flex-1"
type="number"
@update:model-value="
validateNumberValue(rateLimitConfig.fields.rate_limit_max)
"
/>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script lang="ts" setup>
import { useVModel } from '@vueuse/core';
import { computed } from 'vue';
import { useI18n } from '~/composables/i18n';
import { useToast } from '~/composables/toast';
import { ServerConfigs } from '~/helpers/configs';
import IconHelpCircle from '~icons/lucide/help-circle';
const t = useI18n();
const toast = useToast();
const props = defineProps<{
config: ServerConfigs;
}>();
const emit = defineEmits<{
(e: 'update:config', v: ServerConfigs): void;
}>();
const workingConfigs = useVModel(props, 'config', emit);
// Get or set rate limit from workingConfigs
const rateLimitConfig = computed({
get: () => workingConfigs.value?.rateLimitConfigs,
set: (value) => (workingConfigs.value.rateLimitConfigs = value),
});
const validateNumberValue = (value: string | number) => {
const num = typeof value === 'string' ? parseInt(value, 10) : value;
if (isNaN(num) || num <= 0) {
toast.error(t('configs.invalid_number'));
}
};
</script>
<style lang="scss">
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
</style>

View file

@ -68,6 +68,8 @@ const {
updateDataSharingConfigs, updateDataSharingConfigs,
toggleSMTPConfigs, toggleSMTPConfigs,
toggleUserHistoryStore, toggleUserHistoryStore,
updateRateLimitConfigs,
updateAuthTokenConfigs,
} = useConfigHandler(props.workingConfigs); } = useConfigHandler(props.workingConfigs);
// Call relevant mutations on component mount and initiate server restart // Call relevant mutations on component mount and initiate server restart
@ -134,6 +136,22 @@ onMounted(async () => {
if (!userHistoryStoreResult) { if (!userHistoryStoreResult) {
return triggerComponentUnMount(); return triggerComponentUnMount();
} }
const rateLimitResult = await updateRateLimitConfigs(
updateInfraConfigsMutation
);
if (!rateLimitResult) {
return triggerComponentUnMount();
}
const authTokenResult = await updateAuthTokenConfigs(
updateInfraConfigsMutation
);
if (!authTokenResult) {
return triggerComponentUnMount();
}
} }
restart.value = true; restart.value = true;

View file

@ -60,23 +60,27 @@
:on="Boolean(smtpConfigs.fields[field.key])" :on="Boolean(smtpConfigs.fields[field.key])"
@change="toggleCheckbox(field)" @change="toggleCheckbox(field)"
> >
{{ field.name }} {{ makeReadableKey(field.name, true) }}
</HoppSmartCheckbox> </HoppSmartCheckbox>
</div> </div>
<span v-else> <span v-else>
<label>{{ field.name }}</label> <label>{{ makeReadableKey(field.name, true) }}</label>
<span class="flex max-w-lg"> <span class="flex max-w-lg">
<HoppSmartInput <HoppSmartInput
v-model="smtpConfigs.fields[field.key]" v-model="smtpConfigs.fields[field.key]"
:type="isMasked(field.key) ? 'password' : 'text'" :type="isMasked(field.key) ? 'password' : 'text'"
:autofocus="false" :autofocus="false"
class="!my-2 !bg-primaryLight flex-1" class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
/> input-styles="!border-0"
<HoppButtonSecondary >
:icon="isMasked(field.key) ? IconEye : IconEyeOff" <template #button>
class="bg-primaryLight h-9 mt-2" <HoppButtonSecondary
@click="toggleMask(field.key)" :icon="isMasked(field.key) ? IconEye : IconEyeOff"
/> class="bg-primaryLight rounded"
@click="toggleMask(field.key)"
/>
</template>
</HoppSmartInput>
</span> </span>
<div <div
@ -103,6 +107,7 @@ import { useVModel } from '@vueuse/core';
import { computed, reactive, watch } from 'vue'; import { computed, reactive, watch } from 'vue';
import { useI18n } from '~/composables/i18n'; import { useI18n } from '~/composables/i18n';
import { hasInputValidationFailed, ServerConfigs } from '~/helpers/configs'; import { hasInputValidationFailed, ServerConfigs } from '~/helpers/configs';
import { makeReadableKey } from '~/helpers/utils/readableKey';
import IconEye from '~icons/lucide/eye'; import IconEye from '~icons/lucide/eye';
import IconEyeOff from '~icons/lucide/eye-off'; import IconEyeOff from '~icons/lucide/eye-off';
import IconHelpCircle from '~icons/lucide/help-circle'; import IconHelpCircle from '~icons/lucide/help-circle';

View file

@ -0,0 +1,43 @@
<template>
<div>
<div
class="flex items-center space-x-3"
:aria-expanded="isOpen"
:aria-controls="UID"
>
<slot
name="header"
:is-open="isOpen"
:toggle-accordion="toggleAccordion"
/>
</div>
<div v-show="isOpen" :aria-labelledby="UID" :id="UID">
<slot name="content" />
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
const UID = 'accordion-' + Math.random().toString(36).substr(2, 9);
const props = defineProps<{
initialOpen?: boolean;
}>();
const isOpen = ref(false);
watch(
() => props.initialOpen,
(newVal) => {
isOpen.value = newVal ?? false;
},
{ immediate: true }
);
const toggleAccordion = () => {
isOpen.value = !isOpen.value;
};
</script>

View file

@ -136,6 +136,25 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
getFieldValue(InfraConfigEnum.MailerUseCustomConfigs) === 'true', getFieldValue(InfraConfigEnum.MailerUseCustomConfigs) === 'true',
}, },
}, },
tokenConfigs: {
name: 'token',
fields: {
jwt_secret: getFieldValue(InfraConfigEnum.JwtSecret),
token_salt_complexity: getFieldValue(
InfraConfigEnum.TokenSaltComplexity
),
magic_link_token_validity: getFieldValue(
InfraConfigEnum.MagicLinkTokenValidity
),
refresh_token_validity: getFieldValue(
InfraConfigEnum.RefreshTokenValidity
),
access_token_validity: getFieldValue(
InfraConfigEnum.AccessTokenValidity
),
session_secret: getFieldValue(InfraConfigEnum.SessionSecret),
},
},
dataSharingConfigs: { dataSharingConfigs: {
name: 'data_sharing', name: 'data_sharing',
enabled: !!infraConfigs.value.find( enabled: !!infraConfigs.value.find(
@ -150,6 +169,13 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
config.value === 'ENABLE' config.value === 'ENABLE'
), ),
}, },
rateLimitConfigs: {
name: 'rate_limit',
fields: {
rate_limit_ttl: getFieldValue(InfraConfigEnum.RateLimitTtl),
rate_limit_max: getFieldValue(InfraConfigEnum.RateLimitMax),
},
},
}; };
// Cloning the current configs to working configs // Cloning the current configs to working configs
@ -165,18 +191,39 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
Check if any of the config fields are empty Check if any of the config fields are empty
*/ */
const isFieldEmpty = (field: string | boolean) => { const isFieldEmpty = (field: string | boolean) => {
if (typeof field === 'boolean') { if (typeof field === 'boolean' || typeof field === 'number') {
return false; return false;
} }
return field.trim() === ''; return field.trim() === '';
}; };
/**
* Check if the field is not valid
* This is used to validate number fields, ensuring they are not NaN or less than or equal to zero.
* @param field Field value to validate
* @returns Boolean indicating if the field is valid
*/
const isFieldNotValid = (field: string | boolean) => {
if (typeof field === 'boolean') {
return false;
}
const num = Number(field);
if (isNaN(num) && typeof field === 'string') {
return field.trim() === '';
}
return num <= 0;
};
const AreAnyConfigFieldsEmpty = (config: ServerConfigs): boolean => { const AreAnyConfigFieldsEmpty = (config: ServerConfigs): boolean => {
const sections: Array<ConfigSection> = [ const sections: Array<ConfigSection> = [
config.providers.github, config.providers.github,
config.providers.google, config.providers.google,
config.providers.microsoft, config.providers.microsoft,
config.mailConfigs, config.mailConfigs,
config.rateLimitConfigs,
config.tokenConfigs,
]; ];
const hasSectionWithEmptyFields = sections.some((section) => { const hasSectionWithEmptyFields = sections.some((section) => {
@ -197,6 +244,11 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
); );
} }
// This section has no enabled property, so we check fields directly
// eg: tokenConfigs, rateLimitConfigs
if (!('enabled' in section))
Object.values(section.fields).some(isFieldNotValid);
return ( return (
section.enabled && Object.values(section.fields).some(isFieldEmpty) section.enabled && Object.values(section.fields).some(isFieldEmpty)
); );
@ -407,6 +459,118 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
'configs.user_history_store.toggle_failure' 'configs.user_history_store.toggle_failure'
); );
const updateRateLimitConfigs = (
updateRateLimitMutation: UseMutationResponse<UpdateInfraConfigsMutation>
) => {
if (!updatedConfigs?.rateLimitConfigs) {
toast.error(t('configs.rate_limit.input_validation_error'));
return false;
}
const rateLimitTtl = String(
updatedConfigs?.rateLimitConfigs.fields.rate_limit_ttl
);
const rateLimitMax = String(
updatedConfigs?.rateLimitConfigs.fields.rate_limit_max
);
if (isFieldEmpty(rateLimitTtl) || isFieldEmpty(rateLimitMax)) {
toast.error(t('configs.rate_limit.input_validation_error'));
return false;
}
const rateLimitConfigs: InfraConfigArgs[] = [
{
name: InfraConfigEnum.RateLimitTtl,
value: String(rateLimitTtl),
},
{
name: InfraConfigEnum.RateLimitMax,
value: String(rateLimitMax),
},
];
return executeMutation(
updateRateLimitMutation,
{
infraConfigs: rateLimitConfigs,
},
'configs.rate_limit.update_failure'
);
};
const updateAuthTokenConfigs = (
updateAuthTokenMutation: UseMutationResponse<UpdateInfraConfigsMutation>
) => {
if (!updatedConfigs?.tokenConfigs) {
toast.error(t('configs.auth_providers.token.update_failure'));
return false;
}
const jwtSecret = String(updatedConfigs?.tokenConfigs.fields.jwt_secret);
const tokenSaltComplexity = String(
updatedConfigs?.tokenConfigs.fields.token_salt_complexity
);
const magicLinkTokenValidity = String(
updatedConfigs?.tokenConfigs.fields.magic_link_token_validity
);
const refreshTokenValidity = String(
updatedConfigs?.tokenConfigs.fields.refresh_token_validity
);
const accessTokenValidity = String(
updatedConfigs?.tokenConfigs.fields.access_token_validity
);
const sessionSecret = String(
updatedConfigs?.tokenConfigs.fields.session_secret
);
if (
isFieldEmpty(jwtSecret) ||
isFieldEmpty(tokenSaltComplexity) ||
isFieldEmpty(magicLinkTokenValidity) ||
isFieldEmpty(refreshTokenValidity) ||
isFieldEmpty(accessTokenValidity) ||
isFieldEmpty(sessionSecret)
) {
toast.error(t('configs.auth_providers.token.update_failure'));
return false;
}
const authTokenConfigs: InfraConfigArgs[] = [
{
name: InfraConfigEnum.JwtSecret,
value: jwtSecret,
},
{
name: InfraConfigEnum.TokenSaltComplexity,
value: tokenSaltComplexity,
},
{
name: InfraConfigEnum.MagicLinkTokenValidity,
value: magicLinkTokenValidity,
},
{
name: InfraConfigEnum.RefreshTokenValidity,
value: refreshTokenValidity,
},
{
name: InfraConfigEnum.AccessTokenValidity,
value: accessTokenValidity,
},
{
name: InfraConfigEnum.SessionSecret,
value: sessionSecret,
},
];
return executeMutation(
updateAuthTokenMutation,
{
infraConfigs: authTokenConfigs,
},
'configs.auth_providers.token.update_failure'
);
};
return { return {
currentConfigs, currentConfigs,
workingConfigs, workingConfigs,
@ -414,6 +578,8 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
updateDataSharingConfigs, updateDataSharingConfigs,
toggleSMTPConfigs, toggleSMTPConfigs,
toggleUserHistoryStore, toggleUserHistoryStore,
updateRateLimitConfigs,
updateAuthTokenConfigs,
updateInfraConfigs, updateInfraConfigs,
resetInfraConfigs, resetInfraConfigs,
fetchingInfraConfigs, fetchingInfraConfigs,

View file

@ -0,0 +1,376 @@
import { onMounted, ref, watch } from 'vue';
import { useI18n } from './i18n';
import { useToast } from './toast';
import { auth } from '~/helpers/auth';
import { InfraConfigEnum } from '~/helpers/backend/graphql';
import { getLocalConfig, setLocalConfig } from '~/helpers/localpersistence';
import { makeReadableKey } from '~/helpers/utils/readableKey';
export type OAuthProvider = 'GOOGLE' | 'GITHUB' | 'MICROSOFT';
export type EnabledConfig = OAuthProvider | 'MAILER' | 'EMAIL';
// common OAuth keys used across providers
type OAuthKeys = 'CLIENT_ID' | 'CLIENT_SECRET' | 'CALLBACK_URL' | 'SCOPE';
// Microsoft specific keys
type MicrosoftKeys = OAuthKeys | 'TENANT';
type OAuthConfig<Keys extends string, Prefix extends string> = {
[K in Keys as `${Prefix}_${K}`]: string;
};
// Mailer specific keys
export type MailerConfigKeys =
| 'SMTP_ENABLE'
| 'USE_CUSTOM_CONFIGS'
| 'SMTP_URL'
| 'ADDRESS_FROM'
| 'SMTP_HOST'
| 'SMTP_PORT'
| 'SMTP_SECURE'
| 'SMTP_USER'
| 'SMTP_PASSWORD'
| 'TLS_REJECT_UNAUTHORIZED';
export type Configs = {
oAuthProviders: {
GOOGLE: OAuthConfig<OAuthKeys, 'GOOGLE'>;
GITHUB: OAuthConfig<OAuthKeys, 'GITHUB'>;
MICROSOFT: OAuthConfig<MicrosoftKeys, 'MICROSOFT'>;
};
mailerConfigs: {
[K in `MAILER_${MailerConfigKeys}`]: string;
};
};
export type OnBoardingSummary = {
type: 'success' | 'error';
message: string;
description: string;
configsAdded: string[];
};
function mapOAuthProviders(
configs: Partial<Record<InfraConfigEnum, string>>
): Configs['oAuthProviders'] {
return {
GOOGLE: {
GOOGLE_CLIENT_ID: configs.GOOGLE_CLIENT_ID ?? '',
GOOGLE_CLIENT_SECRET: configs.GOOGLE_CLIENT_SECRET ?? '',
GOOGLE_CALLBACK_URL: '',
GOOGLE_SCOPE: configs.GOOGLE_SCOPE ?? '',
},
GITHUB: {
GITHUB_CLIENT_ID: configs.GITHUB_CLIENT_ID ?? '',
GITHUB_CLIENT_SECRET: configs.GITHUB_CLIENT_SECRET ?? '',
GITHUB_CALLBACK_URL: '',
GITHUB_SCOPE: configs.GITHUB_SCOPE ?? '',
},
MICROSOFT: {
MICROSOFT_CLIENT_ID: configs.MICROSOFT_CLIENT_ID ?? '',
MICROSOFT_CLIENT_SECRET: configs.MICROSOFT_CLIENT_SECRET ?? '',
MICROSOFT_CALLBACK_URL: '',
MICROSOFT_SCOPE: configs.MICROSOFT_SCOPE ?? '',
MICROSOFT_TENANT: configs.MICROSOFT_TENANT ?? '',
},
};
}
function mapMailerConfigs(
configs: Partial<Record<InfraConfigEnum, string>>
): Configs['mailerConfigs'] {
return {
MAILER_SMTP_ENABLE: configs.MAILER_SMTP_ENABLE ?? '',
MAILER_USE_CUSTOM_CONFIGS: configs.MAILER_USE_CUSTOM_CONFIGS || 'false',
MAILER_SMTP_URL: configs.MAILER_SMTP_URL ?? '',
MAILER_ADDRESS_FROM: configs.MAILER_ADDRESS_FROM ?? '',
MAILER_SMTP_HOST: configs.MAILER_SMTP_HOST ?? '',
MAILER_SMTP_PORT: configs.MAILER_SMTP_PORT ?? '',
MAILER_SMTP_SECURE: configs.MAILER_SMTP_SECURE || 'false',
MAILER_SMTP_USER: configs.MAILER_SMTP_USER ?? '',
MAILER_SMTP_PASSWORD: configs.MAILER_SMTP_PASSWORD ?? '',
MAILER_TLS_REJECT_UNAUTHORIZED:
configs.MAILER_TLS_REJECT_UNAUTHORIZED || 'false',
};
}
/**
* The handler for onboarding configuration.
* This composable manages the state and logic for onboarding configurations,
* including enabling/disabling configs, validating inputs,
* and submitting the onboarding form.
* @returns Composable for handling onboarding configuration.
*/
export function useOnboardingConfigHandler() {
const t = useI18n();
const toast = useToast();
const enabledConfigs = ref<EnabledConfig[]>([]);
const isProvidersLoading = ref(false);
const submittingConfigs = ref(false);
const onBoardingSummary = ref<OnBoardingSummary>({
type: 'success',
message: t('onboarding.addConfigsSuccess'),
description: t('onboarding.addConfigsDescription'),
configsAdded: [] as string[],
});
const currentConfigs = ref<Configs>({
oAuthProviders: mapOAuthProviders({}),
mailerConfigs: mapMailerConfigs({}),
});
const enableConfig = (config: EnabledConfig) => {
if (!enabledConfigs.value.includes(config)) {
enabledConfigs.value.push(config);
}
};
const toggleConfig = (key: EnabledConfig | 'OAUTH' | 'EMAIL') => {
if (key === 'OAUTH') {
enabledConfigs.value = enabledConfigs.value.filter(
(c) => !['GOOGLE', 'GITHUB', 'MICROSOFT'].includes(c)
);
return;
}
if (key === 'EMAIL') {
const hasEmail = enabledConfigs.value.includes('EMAIL');
const hasMailer = enabledConfigs.value.includes('MAILER');
enabledConfigs.value = enabledConfigs.value.filter(
(c) => c !== 'EMAIL' && c !== 'MAILER'
);
if (!hasEmail || !hasMailer) {
enabledConfigs.value.push('EMAIL', 'MAILER');
}
return;
}
if (enabledConfigs.value.includes(key)) {
enabledConfigs.value = enabledConfigs.value.filter((c) => c !== key);
} else {
enableConfig(key);
}
};
const toggleSmtpConfig = () => {
const current = currentConfigs.value.mailerConfigs.MAILER_SMTP_ENABLE;
currentConfigs.value.mailerConfigs.MAILER_SMTP_ENABLE =
current === 'true' ? 'false' : 'true';
};
// Set callback URLs for OAuth providers based on the current backend API URL
const setCallbackUrls = () => {
const base = import.meta.env.VITE_BACKEND_API_URL;
const oAuth = currentConfigs.value.oAuthProviders;
if (oAuth.GOOGLE.GOOGLE_CLIENT_ID) {
oAuth.GOOGLE.GOOGLE_CALLBACK_URL = `${base}/auth/google/callback`;
}
if (oAuth.GITHUB.GITHUB_CLIENT_ID) {
oAuth.GITHUB.GITHUB_CALLBACK_URL = `${base}/auth/github/callback`;
}
if (oAuth.MICROSOFT.MICROSOFT_CLIENT_ID) {
oAuth.MICROSOFT.MICROSOFT_CALLBACK_URL = `${base}/auth/microsoft/callback`;
}
};
const makeOnboardingSummary = (error?: Error): OnBoardingSummary => {
const addedConfigs = enabledConfigs.value;
if (addedConfigs.length === 0) {
return {
type: 'error',
message: t('onboarding.addConfigsError'),
description: t('onboarding.addConfigsDescription', {
error: error?.message || t('onboarding.addConfigsDefaultError'),
}),
configsAdded: [],
};
}
return {
type: 'success',
message: t('onboarding.addConfigsSuccess'),
description: t('onboarding.addConfigsDescription'),
configsAdded: addedConfigs.filter((key) => key !== 'MAILER'),
};
};
/**
* Filters out unnecessary configs based on the current state.
* For example, if MAILER_USE_CUSTOM_CONFIGS is false,
* we don't need MAILER_SMTP_URL, MAILER_TLS_REJECT_UNAUTHORIZED, MAILER_SMTP_SECURE.
* @param keys Array of config keys to filter
* @returns Filtered array of keys that are needed based on the current state
*/
const filterNeededConfigs = (keys: string[]) => {
const mailer = currentConfigs.value.mailerConfigs;
const usingCustom = mailer.MAILER_USE_CUSTOM_CONFIGS === 'true';
return keys.filter((key) => {
if (!key.startsWith('MAILER_')) return true;
if (!enabledConfigs.value.includes('MAILER')) return false;
if (!usingCustom) {
return ['MAILER_SMTP_URL', 'MAILER_ADDRESS_FROM'].includes(key);
}
return [
'MAILER_SMTP_HOST',
'MAILER_SMTP_PORT',
'MAILER_SMTP_USER',
'MAILER_SMTP_PASSWORD',
].includes(key);
});
};
/**
* Validates the provided configs.
* Checks if all required fields are filled and returns a filtered object
* with only the enabled configs that have values.
* @param configs Object containing config key-value pairs
* @returns Filtered object with valid configs or undefined if validation fails
*/
const validateConfigs = (configs: Partial<Record<string, string>>) => {
if (!configs || Object.keys(configs).length === 0) {
toast.error(t('onboarding.addConfigsError'));
return;
}
const relevantKeys = Object.keys(configs).filter((key) =>
enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig)
);
const neededKeys = filterNeededConfigs(relevantKeys);
const allFilled = neededKeys.every((key) => configs[key]);
if (!allFilled) {
neededKeys.forEach((key) => {
if (!configs[key])
toast.error(
`Please fill the required field: ${makeReadableKey(key)}`
);
});
return;
}
return Object.fromEntries(
Object.entries(configs).filter(
([key, val]) =>
enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig) &&
val
)
);
};
/**
* Adds the onboarding configs to the backend.
* It validates the configs, prepares the payload,
* and sends it to the backend API.
* We set the token in localStorage for re-fetching configs later.
* @returns The token for re-fetching configs or undefined if failed
*/
const addOnBoardingConfigs = async () => {
submittingConfigs.value = true;
const payload = {
...currentConfigs.value.oAuthProviders.GOOGLE,
...currentConfigs.value.oAuthProviders.GITHUB,
...currentConfigs.value.oAuthProviders.MICROSOFT,
...currentConfigs.value.mailerConfigs,
};
const validated = validateConfigs(payload);
if (!validated || Object.keys(validated).length === 0) {
toast.error('Please add at least one config');
return;
}
const configWithAuth = {
...validated,
[InfraConfigEnum.ViteAllowedAuthProviders]: enabledConfigs.value
.filter((x) => x !== 'MAILER')
.join(','),
};
try {
const res = await auth.addOnBoardingConfigs(configWithAuth);
if (res?.token) {
setLocalConfig('access_token', res.token);
toast.success('Onboarding configs added successfully');
onBoardingSummary.value = makeOnboardingSummary();
return res;
}
} catch (err) {
console.error('Failed to add onboarding configs', err);
toast.error('Failed to add onboarding configs');
onBoardingSummary.value = makeOnboardingSummary(err as Error);
} finally {
submittingConfigs.value = false;
}
};
// Fetch onboarding configs on mount and populate the currentConfigs
// and enabledConfigs based on the response.
// This is used to pre-fill the form with existing configs.
onMounted(async () => {
try {
isProvidersLoading.value = true;
const token = getLocalConfig('access_token');
if (!token) return;
const configs = await auth.getOnboardingConfigs(token);
if (!configs) return;
const allowed = configs[InfraConfigEnum.ViteAllowedAuthProviders];
if (allowed) {
enabledConfigs.value = allowed.split(',') as EnabledConfig[];
}
currentConfigs.value = {
oAuthProviders: mapOAuthProviders(configs),
mailerConfigs: mapMailerConfigs(configs),
};
} catch (err) {
console.error('Error fetching onboarding configs', err);
} finally {
isProvidersLoading.value = false;
}
});
// Watch for changes in currentConfigs and update the callback URLs
// and enable/disable configs based on the SMTP settings.
// This ensures that the form reflects the current state of the configs.
watch(
currentConfigs,
() => {
setCallbackUrls();
if (
currentConfigs.value.mailerConfigs.MAILER_SMTP_ENABLE?.toLowerCase() ===
'true'
) {
// Enable MAILER and EMAIL configs if SMTP is enabled
// because we need to add EMAIL in VITE_ALLOWED_AUTH_PROVIDERS
// and MAILER because the key is used in backend
enableConfig('MAILER');
enableConfig('EMAIL');
}
},
{ deep: true, immediate: true }
);
return {
currentConfigs,
enabledConfigs,
isProvidersLoading,
onBoardingSummary,
submittingConfigs,
toggleConfig,
toggleSmtpConfig,
enabledConfig: enableConfig,
addOnBoardingConfigs,
};
}

View file

@ -48,6 +48,11 @@ export const authEvents$ = new Subject<
AuthEvent | { event: 'token_refresh' } AuthEvent | { event: 'token_refresh' }
>(); >();
export type OnboardingStatus = {
canReRunOnboarding: boolean;
onboardingCompleted: boolean;
};
const currentUser$ = new BehaviorSubject<HoppUser | null>(null); const currentUser$ = new BehaviorSubject<HoppUser | null>(null);
const signOut = async (reloadWindow = false) => { const signOut = async (reloadWindow = false) => {
@ -252,4 +257,36 @@ export const auth = {
return false; return false;
} }
}, },
getOnboardingStatus: async (): Promise<OnboardingStatus | null> => {
try {
const res = await authQuery.getOnboardingStatus();
return res.data;
} catch (err) {
console.error(err);
return null;
}
},
addOnBoardingConfigs: async (config: Record<string, any>) => {
try {
const res = await authQuery.addOnBoardingConfigs(config);
return res.data as {
token: string;
};
} catch (err) {
console.error(err);
return null;
}
},
getOnboardingConfigs: async (token: string) => {
try {
const res = await authQuery.getOnBoardingConfigs(token);
return res.data;
} catch (err) {
console.error(err);
return null;
}
},
}; };

View file

@ -31,5 +31,10 @@ export default {
}), }),
getFirstTimeInfraSetupStatus: () => restApi.get('/site/setup'), getFirstTimeInfraSetupStatus: () => restApi.get('/site/setup'),
updateFirstTimeInfraSetupStatus: () => restApi.put('/site/setup'), updateFirstTimeInfraSetupStatus: () => restApi.put('/site/setup'),
addOnBoardingConfigs: (config: Record<string, any>) =>
restApi.post('/onboarding/config', config),
getOnboardingStatus: () => restApi.get('/onboarding/status'),
getOnBoardingConfigs: (token: string) =>
restApi.get('/onboarding/config?token=' + token),
logout: () => restApi.get('/auth/logout'), logout: () => restApi.get('/auth/logout'),
}; };

View file

@ -58,6 +58,18 @@ export type ServerConfigs = {
}; };
}; };
tokenConfigs: {
name: string;
fields: {
jwt_secret: string;
token_salt_complexity: string;
magic_link_token_validity: string;
refresh_token_validity: string;
access_token_validity: string;
session_secret: string;
};
};
historyConfig: { historyConfig: {
name: string; name: string;
enabled: boolean; enabled: boolean;
@ -67,6 +79,14 @@ export type ServerConfigs = {
name: string; name: string;
enabled: boolean; enabled: boolean;
}; };
rateLimitConfigs: {
name: string;
fields: {
rate_limit_ttl: string;
rate_limit_max: string;
};
};
}; };
export type UpdatedConfigs = { export type UpdatedConfigs = {
@ -82,7 +102,7 @@ export type ConfigTransform = {
export type ConfigSection = { export type ConfigSection = {
name: SsoAuthProviders | string; name: SsoAuthProviders | string;
enabled: boolean; enabled?: boolean;
fields: Record<string, string | boolean>; fields: Record<string, string | boolean>;
}; };
@ -211,6 +231,44 @@ export const HISTORY_STORE_CONFIG: Config[] = [
}, },
]; ];
export const RATE_LIMIT_CONFIGS: Config[] = [
{
name: InfraConfigEnum.RateLimitTtl,
key: 'rate_limit_ttl',
},
{
name: InfraConfigEnum.RateLimitMax,
key: 'rate_limit_max',
},
];
export const TOKEN_VALIDATION_CONFIGS: Config[] = [
{
name: InfraConfigEnum.JwtSecret,
key: 'jwt_secret',
},
{
name: InfraConfigEnum.SessionSecret,
key: 'session_secret',
},
{
name: InfraConfigEnum.TokenSaltComplexity,
key: 'token_salt_complexity',
},
{
name: InfraConfigEnum.MagicLinkTokenValidity,
key: 'magic_link_token_validity',
},
{
name: InfraConfigEnum.RefreshTokenValidity,
key: 'refresh_token_validity',
},
{
name: InfraConfigEnum.AccessTokenValidity,
key: 'access_token_validity',
},
];
export const ALL_CONFIGS = [ export const ALL_CONFIGS = [
GOOGLE_CONFIGS, GOOGLE_CONFIGS,
MICROSOFT_CONFIGS, MICROSOFT_CONFIGS,
@ -219,4 +277,6 @@ export const ALL_CONFIGS = [
CUSTOM_MAIL_CONFIGS, CUSTOM_MAIL_CONFIGS,
DATA_SHARING_CONFIGS, DATA_SHARING_CONFIGS,
HISTORY_STORE_CONFIG, HISTORY_STORE_CONFIG,
RATE_LIMIT_CONFIGS,
TOKEN_VALIDATION_CONFIGS,
]; ];

View file

@ -0,0 +1,20 @@
/**
* The makeReadableKey function formats a string to be more human-readable.
* It replaces underscores with spaces, converts it to lowercase,
* and capitalizes the first letter.
* @param string The string to be formatted
* @returns A human-readable version of the string
*/
export const makeReadableKey = (string: string, capitalizeAll?: boolean) => {
if (!string) return '';
const val = string.replace(/_/g, ' ').toLocaleLowerCase();
if (capitalizeAll) {
return val
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
};

View file

@ -4,7 +4,8 @@ import { HoppModule } from '.';
const isSetupRoute = (to: unknown) => to === 'setup'; const isSetupRoute = (to: unknown) => to === 'setup';
const isGuestRoute = (to: unknown) => ['index', 'enter'].includes(to as string); const isGuestRoute = (to: unknown) =>
['index', 'enter', 'onboarding'].includes(to as string);
const getFirstTimeInfraSetupStatus = async () => { const getFirstTimeInfraSetupStatus = async () => {
const isInfraNotSetup = await auth.getFirstTimeInfraSetupStatus(); const isInfraNotSetup = await auth.getFirstTimeInfraSetupStatus();
@ -23,9 +24,20 @@ const getFirstTimeInfraSetupStatus = async () => {
* @param {function} next * @param {function} next
* @returns {void} * @returns {void}
*/ */
export default <HoppModule>{ export default <HoppModule>{
async onBeforeRouteChange(to, _from, next) { async onBeforeRouteChange(to, _from, next) {
// Check if onboarding is completed
const onboardingStatus = await auth.getOnboardingStatus();
if (
!onboardingStatus?.onboardingCompleted &&
to.name !== 'onboarding' &&
to.name === 'index'
) {
// If onboarding is not completed, redirect to the onboarding page
return next({ name: 'onboarding' });
}
const res = await auth.getUserDetails(); const res = await auth.getUserDetails();
// Allow performing the silent refresh flow for an invalid access token state // Allow performing the silent refresh flow for an invalid access token state
@ -35,6 +47,15 @@ export default <HoppModule>{
const isAdmin = res.data?.me.isAdmin; const isAdmin = res.data?.me.isAdmin;
if (
onboardingStatus?.onboardingCompleted &&
!onboardingStatus.canReRunOnboarding &&
to.name === 'onboarding'
) {
// If onboarding is completed, redirect to the dashboard
return next({ name: 'index' });
}
// Route Guards // Route Guards
if (!isGuestRoute(to.name) && !isAdmin) { if (!isGuestRoute(to.name) && !isAdmin) {
/** /**

View file

@ -0,0 +1,135 @@
<template>
<main class="flex h-screen flex-col items-center justify-center">
<div
class="fixed top-0 left-0 p-5 flex items-center justify-between w-full"
>
<span class="text-md font-bold">HOPPSCOTCH</span>
<HoppButtonPrimary
v-if="!isFirstTimeSetup"
label="Go to Dashboard"
@click="goToDashboard"
/>
</div>
<div v-if="isLoading" class="flex flex-1 justify-center items-center">
<HoppSmartSpinner />
</div>
<template v-else>
<WelcomeScreen v-if="step === STEP.WELCOME" @next="nextStep" />
<AuthSetup
v-else-if="step === STEP.AUTH"
:is-first-time-setup="isFirstTimeSetup"
@complete-onboarding="(payload:SuccessPayload) => finishOnboarding(payload)"
/>
<CompleteOnboarding
v-else
:on-boarding-summary="onBoardingSummary"
:submitting-configs="submittingConfigs"
@back="prevStep"
@finish="goToDashboard"
/>
</template>
</main>
</template>
<script setup lang="ts">
import { HoppButtonPrimary } from '@hoppscotch/ui';
import { onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import AuthSetup from '~/components/onboarding/AuthSetup.vue';
import CompleteOnboarding from '~/components/onboarding/CompleteScreen.vue';
import WelcomeScreen from '~/components/onboarding/WelcomeScreen.vue';
import { OnBoardingSummary } from '~/composables/useOnboardingConfigHandler';
import { auth } from '~/helpers/auth';
const router = useRouter();
// Steps
enum STEP {
WELCOME = 1,
AUTH = 2,
COMPLETE = 3,
}
type SuccessPayload = {
submittingConfigs: boolean;
summary: OnBoardingSummary;
};
const step = ref<STEP>(STEP.WELCOME);
const isFirstTimeSetup = ref(true);
const isLoading = ref(false);
const onBoardingSummary = ref<OnBoardingSummary>({
type: 'success',
configsAdded: [],
description: '',
message: '',
});
const submittingConfigs = ref(false);
// Sync from query param
const syncStepFromRoute = () => {
const queryStep = parseInt(
router.currentRoute.value.query.step as string,
10
);
if (
!isNaN(queryStep) &&
queryStep >= STEP.WELCOME &&
queryStep <= STEP.COMPLETE
) {
step.value = queryStep;
}
};
const goToDashboard = () => {
router.push({ name: 'dashboard' });
};
// Watch route changes (e.g., browser back/forward)
watch(
() => router.currentRoute.value.query.step,
() => {
if (isFirstTimeSetup.value) syncStepFromRoute();
}
);
// Push to URL when step changes
watch(step, (newStep) => {
router.replace({ name: 'onboarding', query: { step: newStep.toString() } });
});
// Load onboarding status
onMounted(async () => {
const onboardingStatus = await auth.getOnboardingStatus();
isFirstTimeSetup.value = !onboardingStatus?.onboardingCompleted;
isLoading.value = false;
if (!isFirstTimeSetup.value) {
step.value = STEP.AUTH;
} else {
syncStepFromRoute();
}
});
// Step Controls
const nextStep = () => step.value++;
const prevStep = () => step.value--;
const finishOnboarding = (payload: {
submittingConfigs: boolean;
summary: OnBoardingSummary;
}) => {
step.value = STEP.COMPLETE;
onBoardingSummary.value = payload.summary;
submittingConfigs.value = payload.submittingConfigs;
};
</script>
<route lang="yaml">
meta:
layout: empty
</route>

View file

@ -31,6 +31,9 @@
<HoppSmartTab :id="'token'" :label="t('configs.tabs.infra_tokens')"> <HoppSmartTab :id="'token'" :label="t('configs.tabs.infra_tokens')">
<Tokens /> <Tokens />
</HoppSmartTab> </HoppSmartTab>
<HoppSmartTab :id="'rate-limit'" :label="t('configs.tabs.rate_limit')">
<SettingsRateLimit v-model:config="workingConfigs" />
</HoppSmartTab>
<HoppSmartTab id="miscellaneous" :label="t('configs.tabs.miscellaneous')"> <HoppSmartTab id="miscellaneous" :label="t('configs.tabs.miscellaneous')">
<div class="pb-8 px-4 flex flex-col space-y-8 divide-y divide-divider"> <div class="pb-8 px-4 flex flex-col space-y-8 divide-y divide-divider">
<SettingsDataSharing v-model:config="workingConfigs" /> <SettingsDataSharing v-model:config="workingConfigs" />
@ -76,7 +79,7 @@ const showSaveChangesModal = ref(false);
const initiateServerRestart = ref(false); const initiateServerRestart = ref(false);
// Tabs // Tabs
type OptionTabs = 'auth' | 'smtp' | 'token' | 'miscellaneous'; type OptionTabs = 'auth' | 'smtp' | 'token' | 'miscellaneous' | 'rate-limit';
const selectedOptionTab = ref<OptionTabs>('auth'); const selectedOptionTab = ref<OptionTabs>('auth');
// Obtain the current and working configs from the useConfigHandler composable // Obtain the current and working configs from the useConfigHandler composable

File diff suppressed because it is too large Load diff