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:
parent
37671ac9e7
commit
0b7d31a20c
49 changed files with 3330 additions and 899 deletions
55
.env.example
55
.env.example
|
|
@ -2,24 +2,9 @@
|
|||
# Prisma Config
|
||||
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)
|
||||
DATA_ENCRYPTION_KEY="data encryption key with 32 char"
|
||||
|
||||
# Hoppscotch App Domain Config
|
||||
REDIRECT_URL="http://localhost:3000"
|
||||
# Whitelisted origins for the Hoppscotch App.
|
||||
# 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
|
||||
|
|
@ -28,50 +13,10 @@ REDIRECT_URL="http://localhost:3000"
|
|||
# NOT where the app runs. The app itself uses the `app://` protocol with dynamic
|
||||
# bundle names like `app://{bundle-name}/`
|
||||
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------------------------------#
|
||||
|
||||
|
||||
# Base URLs
|
||||
VITE_BASE_URL=http://localhost:3000
|
||||
VITE_SHORTCODE_BASE_URL=http://localhost:3000
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@
|
|||
"handlebars": "4.7.8",
|
||||
"io-ts": "2.2.22",
|
||||
"luxon": "3.7.1",
|
||||
"morgan": "1.10.1",
|
||||
"nodemailer": "7.0.5",
|
||||
"passport": "0.7.0",
|
||||
"passport-github2": "0.1.12",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ import { PubSubModule } from './pubsub/pubsub.module';
|
|||
}),
|
||||
GraphQLModule.forRootAsync<ApolloDriverConfig>({
|
||||
driver: ApolloDriver,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => {
|
||||
return {
|
||||
|
|
@ -92,12 +91,11 @@ import { PubSubModule } from './pubsub/pubsub.module';
|
|||
},
|
||||
}),
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => [
|
||||
{
|
||||
ttl: +configService.get('RATE_LIMIT_TTL'),
|
||||
limit: +configService.get('RATE_LIMIT_MAX'),
|
||||
ttl: +configService.get('INFRA.RATE_LIMIT_TTL'),
|
||||
limit: +configService.get('INFRA.RATE_LIMIT_MAX'),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ export class AuthController {
|
|||
async verify(@Body() data: VerifyMagicDto, @Res() res: Response) {
|
||||
const authTokens = await this.authService.verifyMagicLinkTokens(data);
|
||||
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,
|
||||
);
|
||||
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,
|
||||
true,
|
||||
req.authInfo.state.redirect_uri,
|
||||
this.configService,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -147,6 +148,7 @@ export class AuthController {
|
|||
authTokens.right,
|
||||
true,
|
||||
req.authInfo.state.redirect_uri,
|
||||
this.configService,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +175,7 @@ export class AuthController {
|
|||
authTokens.right,
|
||||
true,
|
||||
req.authInfo.state.redirect_uri,
|
||||
this.configService,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { GoogleStrategy } from './strategies/google.strategy';
|
|||
import { GithubStrategy } from './strategies/github.strategy';
|
||||
import { MicrosoftStrategy } from './strategies/microsoft.strategy';
|
||||
import { AuthProvider, authProviderCheck } from './helper';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
getConfiguredSSOProvidersFromInfraConfig,
|
||||
isInfraConfigTablePopulated,
|
||||
|
|
@ -22,15 +22,14 @@ import { InfraConfigModule } from 'src/infra-config/infra-config.module';
|
|||
UserModule,
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get('JWT_SECRET'),
|
||||
secret: configService.get('INFRA.JWT_SECRET'),
|
||||
}),
|
||||
}),
|
||||
InfraConfigModule,
|
||||
],
|
||||
providers: [AuthService, JwtStrategy, RTJwtStrategy],
|
||||
providers: [AuthService],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {
|
||||
|
|
@ -57,7 +56,7 @@ export class AuthModule {
|
|||
|
||||
return {
|
||||
module: AuthModule,
|
||||
providers,
|
||||
providers: [...providers, JwtStrategy, RTJwtStrategy],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,11 +50,13 @@ export class AuthService {
|
|||
*/
|
||||
private async generateMagicLinkTokens(user: AuthUser) {
|
||||
const salt = await bcrypt.genSalt(
|
||||
parseInt(this.configService.get('TOKEN_SALT_COMPLEXITY')),
|
||||
parseInt(this.configService.get('INFRA.TOKEN_SALT_COMPLEXITY')),
|
||||
);
|
||||
const expiresOn = DateTime.now()
|
||||
.plus({
|
||||
hours: parseInt(this.configService.get('MAGIC_LINK_TOKEN_VALIDITY')),
|
||||
hours: parseInt(
|
||||
this.configService.get('INFRA.MAGIC_LINK_TOKEN_VALIDITY'),
|
||||
),
|
||||
})
|
||||
.toISO()
|
||||
.toString();
|
||||
|
|
@ -106,7 +108,7 @@ export class AuthService {
|
|||
};
|
||||
|
||||
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);
|
||||
|
|
@ -142,7 +144,7 @@ export class AuthService {
|
|||
|
||||
return E.right(<AuthTokens>{
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -41,30 +41,29 @@ export const authCookieHandler = (
|
|||
authTokens: AuthTokens,
|
||||
redirect: boolean,
|
||||
redirectUrl: string | null,
|
||||
configService: ConfigService,
|
||||
) => {
|
||||
const configService = new ConfigService();
|
||||
|
||||
const currentTime = DateTime.now();
|
||||
const accessTokenValidity = currentTime
|
||||
.plus({
|
||||
milliseconds: parseInt(configService.get('ACCESS_TOKEN_VALIDITY')),
|
||||
milliseconds: parseInt(configService.get('INFRA.ACCESS_TOKEN_VALIDITY')),
|
||||
})
|
||||
.toMillis();
|
||||
const refreshTokenValidity = currentTime
|
||||
.plus({
|
||||
milliseconds: parseInt(configService.get('REFRESH_TOKEN_VALIDITY')),
|
||||
milliseconds: parseInt(configService.get('INFRA.REFRESH_TOKEN_VALIDITY')),
|
||||
})
|
||||
.toMillis();
|
||||
|
||||
res.cookie(AuthTokenType.ACCESS_TOKEN, authTokens.access_token, {
|
||||
httpOnly: true,
|
||||
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true',
|
||||
secure: configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true',
|
||||
sameSite: 'lax',
|
||||
maxAge: accessTokenValidity,
|
||||
});
|
||||
res.cookie(AuthTokenType.REFRESH_TOKEN, authTokens.refresh_token, {
|
||||
httpOnly: true,
|
||||
secure: configService.get('ALLOW_SECURE_COOKIES') === 'true',
|
||||
secure: configService.get('INFRA.ALLOW_SECURE_COOKIES') === 'true',
|
||||
sameSite: 'lax',
|
||||
maxAge: refreshTokenValidity,
|
||||
});
|
||||
|
|
@ -74,12 +73,11 @@ export const authCookieHandler = (
|
|||
}
|
||||
|
||||
// check to see if redirectUrl is a whitelisted url
|
||||
const whitelistedOrigins = configService
|
||||
.get('WHITELISTED_ORIGINS')
|
||||
.split(',');
|
||||
const whitelistedOrigins =
|
||||
configService.get('WHITELISTED_ORIGINS')?.split(',') ?? [];
|
||||
if (!whitelistedOrigins.includes(redirectUrl))
|
||||
// if it is not redirect by default to REDIRECT_URL
|
||||
redirectUrl = configService.get('REDIRECT_URL');
|
||||
// if it is not redirect by default to App
|
||||
redirectUrl = configService.get('VITE_BASE_URL');
|
||||
|
||||
return res.status(HttpStatus.OK).redirect(redirectUrl);
|
||||
};
|
||||
|
|
@ -121,11 +119,7 @@ export function authProviderCheck(
|
|||
throwErr(AUTH_PROVIDER_NOT_SPECIFIED);
|
||||
}
|
||||
|
||||
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS
|
||||
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
||||
provider.trim().toUpperCase(),
|
||||
)
|
||||
: [];
|
||||
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS?.split(',') ?? [];
|
||||
|
||||
if (!envVariables.includes(provider.toUpperCase())) return false;
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||
),
|
||||
),
|
||||
]),
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
secretOrKey: configService.get('INFRA.JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
|||
return RTCookie;
|
||||
},
|
||||
]),
|
||||
secretOrKey: configService.get<string>('JWT_SECRET'),
|
||||
secretOrKey: configService.get('INFRA.JWT_SECRET'),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,13 +36,6 @@ export const JSON_INVALID = 'json_invalid';
|
|||
*/
|
||||
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
|
||||
* (SSO Strategies)
|
||||
|
|
@ -50,12 +43,6 @@ export const AUTH_PROVIDER_NOT_CONFIGURED =
|
|||
export const 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
|
||||
*/
|
||||
|
|
@ -68,18 +55,6 @@ export const ENV_NOT_FOUND_KEY_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';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* (FirebaseService)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
import { AuthProvider } from 'src/auth/helper';
|
||||
import {
|
||||
AUTH_PROVIDER_NOT_CONFIGURED,
|
||||
ENV_INVALID_DATA_ENCRYPTION_KEY,
|
||||
} from 'src/errors';
|
||||
import { ENV_INVALID_DATA_ENCRYPTION_KEY } from 'src/errors';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { InfraConfigEnum } from 'src/types/InfraConfig';
|
||||
import { decrypt, encrypt, throwErr } from 'src/utils';
|
||||
import { decrypt, encrypt } from 'src/utils';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { InfraConfig } from '@prisma/client';
|
||||
|
||||
export enum ServiceStatus {
|
||||
ENABLE = 'ENABLE',
|
||||
|
|
@ -17,43 +13,52 @@ export enum ServiceStatus {
|
|||
type DefaultInfraConfig = {
|
||||
name: InfraConfigEnum;
|
||||
value: string;
|
||||
lastSyncedEnvFileValue: string;
|
||||
isEncrypted: boolean;
|
||||
};
|
||||
|
||||
const AuthProviderConfigurations = {
|
||||
[AuthProvider.GOOGLE]: [
|
||||
InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
InfraConfigEnum.GOOGLE_CLIENT_SECRET,
|
||||
InfraConfigEnum.GOOGLE_CALLBACK_URL,
|
||||
InfraConfigEnum.GOOGLE_SCOPE,
|
||||
],
|
||||
[AuthProvider.GITHUB]: [
|
||||
InfraConfigEnum.GITHUB_CLIENT_ID,
|
||||
InfraConfigEnum.GITHUB_CLIENT_SECRET,
|
||||
InfraConfigEnum.GITHUB_CALLBACK_URL,
|
||||
InfraConfigEnum.GITHUB_SCOPE,
|
||||
],
|
||||
[AuthProvider.MICROSOFT]: [
|
||||
InfraConfigEnum.MICROSOFT_CLIENT_ID,
|
||||
InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
|
||||
InfraConfigEnum.MICROSOFT_CALLBACK_URL,
|
||||
InfraConfigEnum.MICROSOFT_SCOPE,
|
||||
InfraConfigEnum.MICROSOFT_TENANT,
|
||||
],
|
||||
[AuthProvider.EMAIL]:
|
||||
process.env.MAILER_USE_CUSTOM_CONFIGS === 'true'
|
||||
? [
|
||||
InfraConfigEnum.MAILER_SMTP_HOST,
|
||||
InfraConfigEnum.MAILER_SMTP_PORT,
|
||||
InfraConfigEnum.MAILER_SMTP_SECURE,
|
||||
InfraConfigEnum.MAILER_SMTP_USER,
|
||||
InfraConfigEnum.MAILER_SMTP_PASSWORD,
|
||||
InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
]
|
||||
: [InfraConfigEnum.MAILER_SMTP_URL, InfraConfigEnum.MAILER_ADDRESS_FROM],
|
||||
};
|
||||
/**
|
||||
* Returns a mapping of authentication providers to their required configuration keys based on the current environment configuration.
|
||||
*/
|
||||
export function getAuthProviderRequiredKeys(
|
||||
env: Record<string, any>,
|
||||
): Record<AuthProvider, InfraConfigEnum[]> {
|
||||
return {
|
||||
[AuthProvider.GOOGLE]: [
|
||||
InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
InfraConfigEnum.GOOGLE_CLIENT_SECRET,
|
||||
InfraConfigEnum.GOOGLE_CALLBACK_URL,
|
||||
InfraConfigEnum.GOOGLE_SCOPE,
|
||||
],
|
||||
[AuthProvider.GITHUB]: [
|
||||
InfraConfigEnum.GITHUB_CLIENT_ID,
|
||||
InfraConfigEnum.GITHUB_CLIENT_SECRET,
|
||||
InfraConfigEnum.GITHUB_CALLBACK_URL,
|
||||
InfraConfigEnum.GITHUB_SCOPE,
|
||||
],
|
||||
[AuthProvider.MICROSOFT]: [
|
||||
InfraConfigEnum.MICROSOFT_CLIENT_ID,
|
||||
InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
|
||||
InfraConfigEnum.MICROSOFT_CALLBACK_URL,
|
||||
InfraConfigEnum.MICROSOFT_SCOPE,
|
||||
InfraConfigEnum.MICROSOFT_TENANT,
|
||||
],
|
||||
[AuthProvider.EMAIL]:
|
||||
env['INFRA'].MAILER_USE_CUSTOM_CONFIGS === 'true'
|
||||
? [
|
||||
InfraConfigEnum.MAILER_SMTP_HOST,
|
||||
InfraConfigEnum.MAILER_SMTP_PORT,
|
||||
InfraConfigEnum.MAILER_SMTP_SECURE,
|
||||
InfraConfigEnum.MAILER_SMTP_USER,
|
||||
InfraConfigEnum.MAILER_SMTP_PASSWORD,
|
||||
InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
]
|
||||
: [
|
||||
InfraConfigEnum.MAILER_SMTP_URL,
|
||||
InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load environment variables from the database and set them in the process
|
||||
|
|
@ -64,10 +69,9 @@ const AuthProviderConfigurations = {
|
|||
export async function loadInfraConfiguration() {
|
||||
try {
|
||||
const prisma = new PrismaService();
|
||||
|
||||
const infraConfigs = await prisma.infraConfig.findMany();
|
||||
|
||||
const environmentObject: Record<string, any> = {};
|
||||
const environmentObject: Record<string, string> = {};
|
||||
infraConfigs.forEach((infraConfig) => {
|
||||
if (infraConfig.isEncrypted) {
|
||||
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'
|
||||
// Reason for not throwing error is, we want successful build during 'postinstall' and generate dist files
|
||||
console.log('Error from loadInfraConfiguration', error);
|
||||
return { INFRA: {} };
|
||||
}
|
||||
}
|
||||
|
|
@ -95,176 +100,206 @@ export async function getDefaultInfraConfigs(): Promise<DefaultInfraConfig[]> {
|
|||
const prisma = new PrismaService();
|
||||
|
||||
// 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 isSecureCookies = determineAllowSecureCookies(
|
||||
process.env.VITE_BASE_URL,
|
||||
);
|
||||
|
||||
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,
|
||||
value: process.env.MAILER_SMTP_ENABLE ?? 'true',
|
||||
lastSyncedEnvFileValue: process.env.MAILER_SMTP_ENABLE ?? 'true',
|
||||
value: 'false',
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS,
|
||||
value: process.env.MAILER_USE_CUSTOM_CONFIGS ?? 'false',
|
||||
lastSyncedEnvFileValue: process.env.MAILER_USE_CUSTOM_CONFIGS ?? 'false',
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_URL,
|
||||
value: encrypt(process.env.MAILER_SMTP_URL),
|
||||
lastSyncedEnvFileValue: encrypt(process.env.MAILER_SMTP_URL),
|
||||
value: null,
|
||||
isEncrypted: true,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_ADDRESS_FROM,
|
||||
value: process.env.MAILER_ADDRESS_FROM,
|
||||
lastSyncedEnvFileValue: process.env.MAILER_ADDRESS_FROM,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_HOST,
|
||||
value: process.env.MAILER_SMTP_HOST,
|
||||
lastSyncedEnvFileValue: process.env.MAILER_SMTP_HOST,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_PORT,
|
||||
value: process.env.MAILER_SMTP_PORT,
|
||||
lastSyncedEnvFileValue: process.env.MAILER_SMTP_PORT,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_SECURE,
|
||||
value: process.env.MAILER_SMTP_SECURE,
|
||||
lastSyncedEnvFileValue: process.env.MAILER_SMTP_SECURE,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_USER,
|
||||
value: process.env.MAILER_SMTP_USER,
|
||||
lastSyncedEnvFileValue: process.env.MAILER_SMTP_USER,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_SMTP_PASSWORD,
|
||||
value: encrypt(process.env.MAILER_SMTP_PASSWORD),
|
||||
lastSyncedEnvFileValue: encrypt(process.env.MAILER_SMTP_PASSWORD),
|
||||
value: null,
|
||||
isEncrypted: true,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
value: process.env.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
lastSyncedEnvFileValue: process.env.MAILER_TLS_REJECT_UNAUTHORIZED,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CLIENT_ID,
|
||||
value: encrypt(process.env.GOOGLE_CLIENT_ID),
|
||||
lastSyncedEnvFileValue: encrypt(process.env.GOOGLE_CLIENT_ID),
|
||||
value: null,
|
||||
isEncrypted: true,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CLIENT_SECRET,
|
||||
value: encrypt(process.env.GOOGLE_CLIENT_SECRET),
|
||||
lastSyncedEnvFileValue: encrypt(process.env.GOOGLE_CLIENT_SECRET),
|
||||
value: null,
|
||||
isEncrypted: true,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_CALLBACK_URL,
|
||||
value: process.env.GOOGLE_CALLBACK_URL,
|
||||
lastSyncedEnvFileValue: process.env.GOOGLE_CALLBACK_URL,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GOOGLE_SCOPE,
|
||||
value: process.env.GOOGLE_SCOPE,
|
||||
lastSyncedEnvFileValue: process.env.GOOGLE_SCOPE,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_CLIENT_ID,
|
||||
value: encrypt(process.env.GITHUB_CLIENT_ID),
|
||||
lastSyncedEnvFileValue: encrypt(process.env.GITHUB_CLIENT_ID),
|
||||
value: null,
|
||||
isEncrypted: true,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_CLIENT_SECRET,
|
||||
value: encrypt(process.env.GITHUB_CLIENT_SECRET),
|
||||
lastSyncedEnvFileValue: encrypt(process.env.GITHUB_CLIENT_SECRET),
|
||||
value: null,
|
||||
isEncrypted: true,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_CALLBACK_URL,
|
||||
value: process.env.GITHUB_CALLBACK_URL,
|
||||
lastSyncedEnvFileValue: process.env.GITHUB_CALLBACK_URL,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.GITHUB_SCOPE,
|
||||
value: process.env.GITHUB_SCOPE,
|
||||
lastSyncedEnvFileValue: process.env.GITHUB_SCOPE,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_CLIENT_ID,
|
||||
value: encrypt(process.env.MICROSOFT_CLIENT_ID),
|
||||
lastSyncedEnvFileValue: encrypt(process.env.MICROSOFT_CLIENT_ID),
|
||||
value: null,
|
||||
isEncrypted: true,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_CLIENT_SECRET,
|
||||
value: encrypt(process.env.MICROSOFT_CLIENT_SECRET),
|
||||
lastSyncedEnvFileValue: encrypt(process.env.MICROSOFT_CLIENT_SECRET),
|
||||
value: null,
|
||||
isEncrypted: true,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_CALLBACK_URL,
|
||||
value: process.env.MICROSOFT_CALLBACK_URL,
|
||||
lastSyncedEnvFileValue: process.env.MICROSOFT_CALLBACK_URL,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_SCOPE,
|
||||
value: process.env.MICROSOFT_SCOPE,
|
||||
lastSyncedEnvFileValue: process.env.MICROSOFT_SCOPE,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.MICROSOFT_TENANT,
|
||||
value: process.env.MICROSOFT_TENANT,
|
||||
lastSyncedEnvFileValue: process.env.MICROSOFT_TENANT,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS,
|
||||
value: configuredSSOProviders,
|
||||
lastSyncedEnvFileValue: configuredSSOProviders,
|
||||
value: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.ALLOW_ANALYTICS_COLLECTION,
|
||||
value: false.toString(),
|
||||
lastSyncedEnvFileValue: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.ANALYTICS_USER_ID,
|
||||
value: generatedAnalyticsUserId,
|
||||
lastSyncedEnvFileValue: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.IS_FIRST_TIME_INFRA_SETUP,
|
||||
value: (await prisma.infraConfig.count()) === 0 ? 'true' : 'false',
|
||||
lastSyncedEnvFileValue: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
{
|
||||
name: InfraConfigEnum.USER_HISTORY_STORE_ENABLED,
|
||||
value: 'true',
|
||||
lastSyncedEnvFileValue: null,
|
||||
isEncrypted: false,
|
||||
},
|
||||
];
|
||||
|
|
@ -311,48 +346,6 @@ export async function getEncryptionRequiredInfraConfigEntries(
|
|||
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
|
||||
* @returns boolean
|
||||
|
|
@ -389,72 +382,31 @@ export function stopApp() {
|
|||
}, 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.
|
||||
* @description Usage every time the app starts by AuthModule to initiate Strategies.
|
||||
* @returns Array of configured SSO providers
|
||||
*/
|
||||
export async function getConfiguredSSOProvidersFromInfraConfig() {
|
||||
const prisma = new PrismaService();
|
||||
const env = await loadInfraConfiguration();
|
||||
const providerConfigKeys = getAuthProviderRequiredKeys(env);
|
||||
|
||||
const allowedAuthProviders: string[] =
|
||||
env['INFRA'].VITE_ALLOWED_AUTH_PROVIDERS.split(',');
|
||||
const configuredAuthProviders: string[] = [];
|
||||
env['INFRA'].VITE_ALLOWED_AUTH_PROVIDERS?.split(',') ?? [];
|
||||
|
||||
const addProviderIfConfigured = (provider) => {
|
||||
const configParameters: string[] = AuthProviderConfigurations[provider];
|
||||
|
||||
const isConfigured = configParameters.every((configParameter) => {
|
||||
return env['INFRA'][configParameter];
|
||||
});
|
||||
if (isConfigured) configuredAuthProviders.push(provider);
|
||||
};
|
||||
|
||||
allowedAuthProviders.forEach((provider) => addProviderIfConfigured(provider));
|
||||
const configuredAuthProviders = allowedAuthProviders.filter((provider) => {
|
||||
const requiredKeys = providerConfigKeys[provider];
|
||||
return requiredKeys?.every((key) => env['INFRA'][key]);
|
||||
});
|
||||
|
||||
if (configuredAuthProviders.length === 0) {
|
||||
await prisma.infraConfig.update({
|
||||
where: { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS },
|
||||
data: { value: null },
|
||||
});
|
||||
return '';
|
||||
} else if (allowedAuthProviders.length !== configuredAuthProviders.length) {
|
||||
const prisma = new PrismaService();
|
||||
await prisma.infraConfig.update({
|
||||
where: { name: InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS },
|
||||
data: { value: configuredAuthProviders.join(',') },
|
||||
|
|
@ -468,6 +420,24 @@ export async function getConfiguredSSOProvidersFromInfraConfig() {
|
|||
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
|
||||
* @returns Generated hashed value
|
||||
|
|
@ -476,3 +446,58 @@ export function generateAnalyticsUserId() {
|
|||
const hashedUserID = randomBytes(20).toString('hex');
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,13 @@ import { Module } from '@nestjs/common';
|
|||
import { InfraConfigService } from './infra-config.service';
|
||||
import { SiteController } from './infra-config.controller';
|
||||
import { InfraConfigResolver } from './infra-config.resolver';
|
||||
import { UserModule } from 'src/user/user.module';
|
||||
import { OnboardingController } from './onboarding.controller';
|
||||
|
||||
@Module({
|
||||
imports: [UserModule],
|
||||
controllers: [SiteController, OnboardingController],
|
||||
providers: [InfraConfigResolver, InfraConfigService],
|
||||
exports: [InfraConfigService],
|
||||
controllers: [SiteController],
|
||||
})
|
||||
export class InfraConfigModule {}
|
||||
|
|
|
|||
|
|
@ -14,15 +14,18 @@ import { InfraConfig } from './infra-config.model';
|
|||
import { PubSubService } from 'src/pubsub/pubsub.service';
|
||||
import { ServiceStatus } from './helper';
|
||||
import * as E from 'fp-ts/Either';
|
||||
import { UserService } from 'src/user/user.service';
|
||||
|
||||
const mockPrisma = mockDeep<PrismaService>();
|
||||
const mockConfigService = mockDeep<ConfigService>();
|
||||
const mockPubsub = mockDeep<PubSubService>();
|
||||
const mockUserService = mockDeep<UserService>();
|
||||
|
||||
const infraConfigService = new InfraConfigService(
|
||||
mockPrisma,
|
||||
mockConfigService,
|
||||
mockPubsub,
|
||||
mockUserService,
|
||||
);
|
||||
|
||||
const INITIALIZED_DATE_CONST = new Date();
|
||||
|
|
|
|||
|
|
@ -25,15 +25,23 @@ import {
|
|||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
ServiceStatus,
|
||||
buildDerivedEnv,
|
||||
getDefaultInfraConfigs,
|
||||
getEncryptionRequiredInfraConfigEntries,
|
||||
getMissingInfraConfigEntries,
|
||||
stopApp,
|
||||
syncInfraConfigWithEnvFile,
|
||||
} from './helper';
|
||||
import { EnableAndDisableSSOArgs, InfraConfigArgs } from './input-args';
|
||||
import { AuthProvider } from 'src/auth/helper';
|
||||
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()
|
||||
export class InfraConfigService implements OnModuleInit {
|
||||
|
|
@ -41,6 +49,7 @@ export class InfraConfigService implements OnModuleInit {
|
|||
private readonly prisma: PrismaService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly pubsub: PubSubService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Sync the InfraConfigs with the .env file, if .env file updates later on
|
||||
const envFileChangesRequired = await syncInfraConfigWithEnvFile();
|
||||
if (envFileChangesRequired.length > 0) {
|
||||
const dbOperations = envFileChangesRequired.map((dbConfig) => {
|
||||
const { id, ...dataObj } = dbConfig;
|
||||
// Derive env variables programmatically if they don't exist or need to be updated
|
||||
const derivedEnv = await buildDerivedEnv();
|
||||
|
||||
if (Object.keys(derivedEnv).length > 0) {
|
||||
const dbOperations = Object.entries(derivedEnv).map(([name, value]) => {
|
||||
return this.prisma.infraConfig.update({
|
||||
where: { id: dbConfig.id },
|
||||
data: dataObj,
|
||||
where: { name: name as InfraConfigEnum },
|
||||
data: { value },
|
||||
});
|
||||
});
|
||||
await Promise.allSettled(dbOperations);
|
||||
|
|
@ -111,7 +120,7 @@ export class InfraConfigService implements OnModuleInit {
|
|||
if (
|
||||
propsToInsert.length > 0 ||
|
||||
encryptionRequiredEntries.length > 0 ||
|
||||
envFileChangesRequired.length > 0
|
||||
Object.keys(derivedEnv).length > 0
|
||||
) {
|
||||
stopApp();
|
||||
}
|
||||
|
|
@ -210,10 +219,16 @@ export class InfraConfigService implements OnModuleInit {
|
|||
* @param infraConfigs InfraConfigs to update
|
||||
* @returns InfraConfig model
|
||||
*/
|
||||
async updateMany(infraConfigs: InfraConfigArgs[]) {
|
||||
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);
|
||||
async updateMany(
|
||||
infraConfigs: InfraConfigArgs[],
|
||||
checkDisallowedKeys: boolean = true,
|
||||
) {
|
||||
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);
|
||||
|
|
@ -374,7 +389,7 @@ export class InfraConfigService implements OnModuleInit {
|
|||
const infra = await this.get(InfraConfigEnum.VITE_ALLOWED_AUTH_PROVIDERS);
|
||||
if (E.isLeft(infra)) return E.left(infra.left);
|
||||
|
||||
const allowedAuthProviders = infra.right.value.split(',');
|
||||
const allowedAuthProviders = infra.right.value?.split(',') ?? [];
|
||||
let updatedAuthProviders = allowedAuthProviders;
|
||||
|
||||
const infraConfigMap = await this.getInfraConfigsMap();
|
||||
|
|
@ -457,9 +472,11 @@ export class InfraConfigService implements OnModuleInit {
|
|||
* @returns string[]
|
||||
*/
|
||||
getAllowedAuthProviders() {
|
||||
return this.configService
|
||||
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
|
||||
.split(',');
|
||||
return (
|
||||
this.configService
|
||||
.get<string>('INFRA.VITE_ALLOWED_AUTH_PROVIDERS')
|
||||
?.split(',') ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -485,6 +502,107 @@ export class InfraConfigService implements OnModuleInit {
|
|||
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)
|
||||
*/
|
||||
|
|
@ -530,98 +648,75 @@ export class InfraConfigService implements OnModuleInit {
|
|||
value: string;
|
||||
}[],
|
||||
) {
|
||||
for (let i = 0; i < infraConfigs.length; i++) {
|
||||
switch (infraConfigs[i].name) {
|
||||
for (const config of infraConfigs) {
|
||||
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:
|
||||
if (
|
||||
infraConfigs[i].value !== 'true' &&
|
||||
infraConfigs[i].value !== 'false'
|
||||
)
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_USE_CUSTOM_CONFIGS:
|
||||
if (
|
||||
infraConfigs[i].value !== 'true' &&
|
||||
infraConfigs[i].value !== 'false'
|
||||
)
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_SMTP_URL:
|
||||
const isValidUrl = validateSMTPUrl(infraConfigs[i].value);
|
||||
if (!isValidUrl) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
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:
|
||||
if (
|
||||
infraConfigs[i].value !== 'true' &&
|
||||
infraConfigs[i].value !== 'false'
|
||||
)
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_SMTP_USER:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_SMTP_PASSWORD:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MAILER_TLS_REJECT_UNAUTHORIZED:
|
||||
if (
|
||||
infraConfigs[i].value !== 'true' &&
|
||||
infraConfigs[i].value !== 'false'
|
||||
)
|
||||
return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
if (value !== 'true' && value !== 'false') return fail();
|
||||
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:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
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:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.GITHUB_CLIENT_ID:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
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:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MICROSOFT_CLIENT_ID:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
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:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
break;
|
||||
case InfraConfigEnum.MICROSOFT_TENANT:
|
||||
if (!infraConfigs[i].value) return E.left(INFRA_CONFIG_INVALID_INPUT);
|
||||
if (!value) return fail();
|
||||
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:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,49 +10,33 @@ function isSMTPCustomConfigsEnabled(value) {
|
|||
return value === 'true';
|
||||
}
|
||||
|
||||
export function getMailerAddressFrom(env, config): string {
|
||||
return (
|
||||
env.INFRA.MAILER_ADDRESS_FROM ??
|
||||
config.get('MAILER_ADDRESS_FROM') ??
|
||||
throwErr(MAILER_SMTP_URL_UNDEFINED)
|
||||
);
|
||||
export function getMailerAddressFrom(env): string {
|
||||
return env.INFRA.MAILER_ADDRESS_FROM ?? throwErr(MAILER_SMTP_URL_UNDEFINED);
|
||||
}
|
||||
|
||||
export function getTransportOption(env, config): TransportType {
|
||||
export function getTransportOption(env): TransportType {
|
||||
const useCustomConfigs = isSMTPCustomConfigsEnabled(
|
||||
env.INFRA.MAILER_USE_CUSTOM_CONFIGS ??
|
||||
config.get('MAILER_USE_CUSTOM_CONFIGS'),
|
||||
env.INFRA.MAILER_USE_CUSTOM_CONFIGS,
|
||||
);
|
||||
|
||||
if (!useCustomConfigs) {
|
||||
console.log('Using simple mailer configuration');
|
||||
return (
|
||||
env.INFRA.MAILER_SMTP_URL ??
|
||||
config.get('MAILER_SMTP_URL') ??
|
||||
throwErr(MAILER_SMTP_URL_UNDEFINED)
|
||||
);
|
||||
return env.INFRA.MAILER_SMTP_URL ?? throwErr(MAILER_SMTP_URL_UNDEFINED);
|
||||
} else {
|
||||
console.log('Using advanced mailer configuration');
|
||||
return {
|
||||
host: env.INFRA.MAILER_SMTP_HOST ?? config.get('MAILER_SMTP_HOST'),
|
||||
port: +(env.INFRA.MAILER_SMTP_PORT ?? config.get('MAILER_SMTP_PORT')),
|
||||
secure:
|
||||
(env.INFRA.MAILER_SMTP_SECURE ?? config.get('MAILER_SMTP_SECURE')) ===
|
||||
'true',
|
||||
host: env.INFRA.MAILER_SMTP_HOST,
|
||||
port: +env.INFRA.MAILER_SMTP_PORT,
|
||||
secure: env.INFRA.MAILER_SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user:
|
||||
env.INFRA.MAILER_SMTP_USER ??
|
||||
config.get('MAILER_SMTP_USER') ??
|
||||
throwErr(MAILER_SMTP_USER_UNDEFINED),
|
||||
env.INFRA.MAILER_SMTP_USER ?? throwErr(MAILER_SMTP_USER_UNDEFINED),
|
||||
pass:
|
||||
env.INFRA.MAILER_SMTP_PASSWORD ??
|
||||
config.get('MAILER_SMTP_PASSWORD') ??
|
||||
throwErr(MAILER_SMTP_PASSWORD_UNDEFINED),
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized:
|
||||
(env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED ??
|
||||
config.get('MAILER_TLS_REJECT_UNAUTHORIZED')) === 'true',
|
||||
rejectUnauthorized: env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED === 'true',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Global, Module } from '@nestjs/common';
|
|||
import { MailerModule as NestMailerModule } from '@nestjs-modules/mailer';
|
||||
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
|
||||
import { MailerService } from './mailer.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { loadInfraConfiguration } from 'src/infra-config/helper';
|
||||
import { getMailerAddressFrom, getTransportOption } from './helper';
|
||||
|
||||
|
|
@ -14,7 +13,6 @@ import { getMailerAddressFrom, getTransportOption } from './helper';
|
|||
})
|
||||
export class MailerModule {
|
||||
static async register() {
|
||||
const config = new ConfigService();
|
||||
const env = await loadInfraConfiguration();
|
||||
|
||||
// If mailer SMTP is DISABLED, return the module without any configuration (service, listener, etc.)
|
||||
|
|
@ -28,9 +26,9 @@ export class MailerModule {
|
|||
// If mailer is ENABLED, return the module with configuration (service, etc.)
|
||||
|
||||
// Determine transport configuration based on custom config flag
|
||||
const transportOption = getTransportOption(env, config);
|
||||
const transportOption = getTransportOption(env);
|
||||
// Get mailer address from environment or config
|
||||
const mailerAddressFrom = getMailerAddressFrom(env, config);
|
||||
const mailerAddressFrom = getMailerAddressFrom(env);
|
||||
|
||||
return {
|
||||
module: MailerModule,
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@ import { NestFactory } from '@nestjs/core';
|
|||
import { json } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import * as cookieParser from 'cookie-parser';
|
||||
import * as crypto from 'crypto';
|
||||
import { ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import * as session from 'express-session';
|
||||
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 { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { InfraTokenModule } from './infra-token/infra-token.module';
|
||||
|
||||
function setupSwagger(app) {
|
||||
function setupSwagger(app, isProduction: boolean) {
|
||||
const swaggerDocPath = '/api-docs';
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
|
|
@ -30,7 +30,7 @@ function setupSwagger(app) {
|
|||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config, {
|
||||
include: [InfraTokenModule],
|
||||
include: isProduction ? [InfraTokenModule] : [],
|
||||
});
|
||||
SwaggerModule.setup(swaggerDocPath, app, document, {
|
||||
swaggerOptions: { persistAuthorization: true, ignoreGlobalPrefix: true },
|
||||
|
|
@ -41,15 +41,11 @@ async function bootstrap() {
|
|||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
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')}`);
|
||||
|
||||
checkEnvironmentAuthProvider(
|
||||
configService.get('INFRA.VITE_ALLOWED_AUTH_PROVIDERS') ??
|
||||
configService.get('VITE_ALLOWED_AUTH_PROVIDERS'),
|
||||
);
|
||||
|
||||
app.use(
|
||||
session({
|
||||
secret:
|
||||
|
|
@ -72,7 +68,7 @@ async function bootstrap() {
|
|||
}),
|
||||
);
|
||||
|
||||
if (configService.get('PRODUCTION') === 'true') {
|
||||
if (isProduction) {
|
||||
console.log('Enabling CORS with production settings');
|
||||
app.enableCors({
|
||||
origin: configService.get('WHITELISTED_ORIGINS').split(','),
|
||||
|
|
@ -95,8 +91,9 @@ async function bootstrap() {
|
|||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -910,7 +910,7 @@ describe('deleteUserFromAllTeams', () => {
|
|||
|
||||
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({
|
||||
where: {
|
||||
userUid: dbTeamMember.userUid,
|
||||
|
|
@ -932,7 +932,7 @@ describe('deleteUserFromAllTeams', () => {
|
|||
|
||||
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({
|
||||
where: {
|
||||
userUid: dbTeamMember.userUid,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,18 @@
|
|||
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_USE_CUSTOM_CONFIGS = 'MAILER_USE_CUSTOM_CONFIGS',
|
||||
MAILER_SMTP_URL = 'MAILER_SMTP_URL',
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ describe('UserSettingsService', () => {
|
|||
|
||||
await userSettingsService.createUserSettings(user, settings.properties);
|
||||
|
||||
expect(mockPubSub.publish).toBeCalledWith(
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`user_settings/${user.uid}/created`,
|
||||
settings,
|
||||
);
|
||||
|
|
@ -126,7 +126,7 @@ describe('UserSettingsService', () => {
|
|||
|
||||
await userSettingsService.updateUserSettings(user, settings.properties);
|
||||
|
||||
expect(mockPubSub.publish).toBeCalledWith(
|
||||
expect(mockPubSub.publish).toHaveBeenCalledWith(
|
||||
`user_settings/${user.uid}/updated`,
|
||||
settings,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,14 +8,7 @@ import { pipe } from 'fp-ts/lib/function';
|
|||
import * as O from 'fp-ts/Option';
|
||||
import * as T from 'fp-ts/Task';
|
||||
import * as TE from 'fp-ts/TaskEither';
|
||||
import { AuthProvider } from './auth/helper';
|
||||
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 { ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY, JSON_INVALID } from './errors';
|
||||
import { TeamAccessRole } from './team/team.model';
|
||||
import { RESTError } from './types/RESTError';
|
||||
import * as crypto from 'crypto';
|
||||
|
|
@ -234,36 +227,6 @@ export function isValidLength(title: string, length: number) {
|
|||
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
|
||||
* 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) {
|
||||
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 cipher = crypto.createCipheriv(
|
||||
|
|
@ -367,7 +330,7 @@ export function decrypt(
|
|||
) {
|
||||
if (!key) throw new Error(ENV_NOT_FOUND_KEY_DATA_ENCRYPTION_KEY);
|
||||
|
||||
if (encryptedData === null || encryptedData === undefined) {
|
||||
if (!encryptedData || encryptedData === '') {
|
||||
return encryptedData;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1332,7 +1332,7 @@
|
|||
"admin_success": "User is now an admin!!",
|
||||
"and": "and",
|
||||
"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_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}?",
|
||||
|
|
|
|||
|
|
@ -56,6 +56,27 @@
|
|||
:label="`${t('auth.send_magic_link')}`"
|
||||
/>
|
||||
</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 class="flex max-w-md flex-col items-center justify-center">
|
||||
<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 IconMicrosoft from "~icons/auth/microsoft"
|
||||
import IconArrowLeft from "~icons/lucide/arrow-left"
|
||||
import IconFileText from "~icons/lucide/file-text"
|
||||
|
||||
import { useService } from "dioc/vue"
|
||||
import { LoginItemDef } from "~/platform/auth"
|
||||
|
|
|
|||
|
|
@ -42,9 +42,21 @@
|
|||
"sso": "SSO",
|
||||
"tenant": "TENANT",
|
||||
"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!!"
|
||||
},
|
||||
"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_validation_error": "Some fields have invalid values. Please correct them before updating the configurations",
|
||||
"data_sharing": {
|
||||
|
|
@ -85,6 +97,14 @@
|
|||
"toggle_failure": "Failed to toggle 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": {
|
||||
"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",
|
||||
|
|
@ -102,6 +122,7 @@
|
|||
"auth": "Authentication",
|
||||
"activity": "Activity",
|
||||
"infra_tokens": "Infra Tokens",
|
||||
"rate_limit": "Rate Limit",
|
||||
"smtp": "SMTP",
|
||||
"miscellaneous": "Miscellaneous",
|
||||
"reset": "Reset Configurations"
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
<div v-else class="flex flex-1 flex-col">
|
||||
<div
|
||||
class="p-6 bg-primaryLight rounded-lg border border-primaryDark shadow"
|
||||
class="p-4 bg-primaryLight rounded-lg border border-primaryDark shadow"
|
||||
>
|
||||
<div
|
||||
v-if="mode === 'sign-in' && allowedAuthProviders"
|
||||
|
|
@ -71,7 +71,10 @@
|
|||
:label="t('state.send_magic_link')"
|
||||
/>
|
||||
</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.configure_auth') }}</p>
|
||||
<div class="mt-5">
|
||||
|
|
@ -102,7 +105,16 @@
|
|||
</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
|
||||
v-if="
|
||||
mode === 'sign-in' &&
|
||||
|
|
@ -151,6 +163,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computedAsync } from '@vueuse/core';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useI18n } from '~/composables/i18n';
|
||||
import { useToast } from '~/composables/toast';
|
||||
|
|
@ -183,6 +196,15 @@ const nonAdminUser = ref(false);
|
|||
|
||||
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 () => {
|
||||
const user = auth.getCurrentUser();
|
||||
if (user && !user.isAdmin) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -61,6 +61,12 @@
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -82,7 +88,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
// Auth Sub Tabs
|
||||
type AuthSubTabs = 'auth-providers' | 'email-auth';
|
||||
type AuthSubTabs = 'auth-providers' | 'email-auth' | 'token';
|
||||
const selectedAuthSubTab = ref<AuthSubTabs>('auth-providers');
|
||||
|
||||
const workingConfigs = useVModel(props, 'config', emit);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
<template
|
||||
v-if="field.applicableProviders.includes(provider.name)"
|
||||
>
|
||||
<label>{{ field.name }}</label>
|
||||
<label>{{ makeReadableKey(field.name, true) }}</label>
|
||||
<span class="flex max-w-lg">
|
||||
<HoppSmartInput
|
||||
v-model="provider.fields[field.key as keyof typeof provider['fields']]"
|
||||
|
|
@ -51,15 +51,21 @@
|
|||
isMasked(provider.name, field.key) ? 'password' : 'text'
|
||||
"
|
||||
:autofocus="false"
|
||||
class="!my-2 !bg-primaryLight flex-1"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:icon="
|
||||
isMasked(provider.name, field.key) ? IconEye : IconEyeOff
|
||||
"
|
||||
class="bg-primaryLight h-9 mt-2"
|
||||
@click="toggleMask(provider.name, field.key)"
|
||||
/>
|
||||
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
|
||||
input-styles="!border-0"
|
||||
>
|
||||
<template #button>
|
||||
<HoppButtonSecondary
|
||||
:icon="
|
||||
isMasked(provider.name, field.key)
|
||||
? IconEye
|
||||
: IconEyeOff
|
||||
"
|
||||
class="bg-primaryLight rounded"
|
||||
@click="toggleMask(provider.name, field.key)"
|
||||
/>
|
||||
</template>
|
||||
</HoppSmartInput>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -75,6 +81,7 @@ import { useVModel } from '@vueuse/core';
|
|||
import { reactive } from 'vue';
|
||||
import { useI18n } from '~/composables/i18n';
|
||||
import { ServerConfigs, SsoAuthProviders } from '~/helpers/configs';
|
||||
import { makeReadableKey } from '~/helpers/utils/readableKey';
|
||||
import IconCircleHelp from '~icons/lucide/circle-help';
|
||||
import IconEye from '~icons/lucide/eye';
|
||||
import IconEyeOff from '~icons/lucide/eye-off';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -68,6 +68,8 @@ const {
|
|||
updateDataSharingConfigs,
|
||||
toggleSMTPConfigs,
|
||||
toggleUserHistoryStore,
|
||||
updateRateLimitConfigs,
|
||||
updateAuthTokenConfigs,
|
||||
} = useConfigHandler(props.workingConfigs);
|
||||
|
||||
// Call relevant mutations on component mount and initiate server restart
|
||||
|
|
@ -134,6 +136,22 @@ onMounted(async () => {
|
|||
if (!userHistoryStoreResult) {
|
||||
return triggerComponentUnMount();
|
||||
}
|
||||
|
||||
const rateLimitResult = await updateRateLimitConfigs(
|
||||
updateInfraConfigsMutation
|
||||
);
|
||||
|
||||
if (!rateLimitResult) {
|
||||
return triggerComponentUnMount();
|
||||
}
|
||||
|
||||
const authTokenResult = await updateAuthTokenConfigs(
|
||||
updateInfraConfigsMutation
|
||||
);
|
||||
|
||||
if (!authTokenResult) {
|
||||
return triggerComponentUnMount();
|
||||
}
|
||||
}
|
||||
|
||||
restart.value = true;
|
||||
|
|
|
|||
|
|
@ -60,23 +60,27 @@
|
|||
:on="Boolean(smtpConfigs.fields[field.key])"
|
||||
@change="toggleCheckbox(field)"
|
||||
>
|
||||
{{ field.name }}
|
||||
{{ makeReadableKey(field.name, true) }}
|
||||
</HoppSmartCheckbox>
|
||||
</div>
|
||||
<span v-else>
|
||||
<label>{{ field.name }}</label>
|
||||
<label>{{ makeReadableKey(field.name, true) }}</label>
|
||||
<span class="flex max-w-lg">
|
||||
<HoppSmartInput
|
||||
v-model="smtpConfigs.fields[field.key]"
|
||||
:type="isMasked(field.key) ? 'password' : 'text'"
|
||||
:autofocus="false"
|
||||
class="!my-2 !bg-primaryLight flex-1"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:icon="isMasked(field.key) ? IconEye : IconEyeOff"
|
||||
class="bg-primaryLight h-9 mt-2"
|
||||
@click="toggleMask(field.key)"
|
||||
/>
|
||||
class="!my-2 !bg-primaryLight flex-1 border border-divider rounded"
|
||||
input-styles="!border-0"
|
||||
>
|
||||
<template #button>
|
||||
<HoppButtonSecondary
|
||||
:icon="isMasked(field.key) ? IconEye : IconEyeOff"
|
||||
class="bg-primaryLight rounded"
|
||||
@click="toggleMask(field.key)"
|
||||
/>
|
||||
</template>
|
||||
</HoppSmartInput>
|
||||
</span>
|
||||
|
||||
<div
|
||||
|
|
@ -103,6 +107,7 @@ import { useVModel } from '@vueuse/core';
|
|||
import { computed, reactive, watch } from 'vue';
|
||||
import { useI18n } from '~/composables/i18n';
|
||||
import { hasInputValidationFailed, ServerConfigs } from '~/helpers/configs';
|
||||
import { makeReadableKey } from '~/helpers/utils/readableKey';
|
||||
import IconEye from '~icons/lucide/eye';
|
||||
import IconEyeOff from '~icons/lucide/eye-off';
|
||||
import IconHelpCircle from '~icons/lucide/help-circle';
|
||||
|
|
|
|||
43
packages/hoppscotch-sh-admin/src/components/ui/Accordion.vue
Normal file
43
packages/hoppscotch-sh-admin/src/components/ui/Accordion.vue
Normal 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>
|
||||
|
|
@ -136,6 +136,25 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
|||
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: {
|
||||
name: 'data_sharing',
|
||||
enabled: !!infraConfigs.value.find(
|
||||
|
|
@ -150,6 +169,13 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
|||
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
|
||||
|
|
@ -165,18 +191,39 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
|||
Check if any of the config fields are empty
|
||||
*/
|
||||
const isFieldEmpty = (field: string | boolean) => {
|
||||
if (typeof field === 'boolean') {
|
||||
if (typeof field === 'boolean' || typeof field === 'number') {
|
||||
return false;
|
||||
}
|
||||
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 sections: Array<ConfigSection> = [
|
||||
config.providers.github,
|
||||
config.providers.google,
|
||||
config.providers.microsoft,
|
||||
config.mailConfigs,
|
||||
config.rateLimitConfigs,
|
||||
config.tokenConfigs,
|
||||
];
|
||||
|
||||
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 (
|
||||
section.enabled && Object.values(section.fields).some(isFieldEmpty)
|
||||
);
|
||||
|
|
@ -407,6 +459,118 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
|||
'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 {
|
||||
currentConfigs,
|
||||
workingConfigs,
|
||||
|
|
@ -414,6 +578,8 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) {
|
|||
updateDataSharingConfigs,
|
||||
toggleSMTPConfigs,
|
||||
toggleUserHistoryStore,
|
||||
updateRateLimitConfigs,
|
||||
updateAuthTokenConfigs,
|
||||
updateInfraConfigs,
|
||||
resetInfraConfigs,
|
||||
fetchingInfraConfigs,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -48,6 +48,11 @@ export const authEvents$ = new Subject<
|
|||
AuthEvent | { event: 'token_refresh' }
|
||||
>();
|
||||
|
||||
export type OnboardingStatus = {
|
||||
canReRunOnboarding: boolean;
|
||||
onboardingCompleted: boolean;
|
||||
};
|
||||
|
||||
const currentUser$ = new BehaviorSubject<HoppUser | null>(null);
|
||||
|
||||
const signOut = async (reloadWindow = false) => {
|
||||
|
|
@ -252,4 +257,36 @@ export const auth = {
|
|||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -31,5 +31,10 @@ export default {
|
|||
}),
|
||||
getFirstTimeInfraSetupStatus: () => restApi.get('/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'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
|
|
@ -67,6 +79,14 @@ export type ServerConfigs = {
|
|||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
rateLimitConfigs: {
|
||||
name: string;
|
||||
fields: {
|
||||
rate_limit_ttl: string;
|
||||
rate_limit_max: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdatedConfigs = {
|
||||
|
|
@ -82,7 +102,7 @@ export type ConfigTransform = {
|
|||
|
||||
export type ConfigSection = {
|
||||
name: SsoAuthProviders | string;
|
||||
enabled: boolean;
|
||||
enabled?: 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 = [
|
||||
GOOGLE_CONFIGS,
|
||||
MICROSOFT_CONFIGS,
|
||||
|
|
@ -219,4 +277,6 @@ export const ALL_CONFIGS = [
|
|||
CUSTOM_MAIL_CONFIGS,
|
||||
DATA_SHARING_CONFIGS,
|
||||
HISTORY_STORE_CONFIG,
|
||||
RATE_LIMIT_CONFIGS,
|
||||
TOKEN_VALIDATION_CONFIGS,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -4,7 +4,8 @@ import { HoppModule } from '.';
|
|||
|
||||
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 isInfraNotSetup = await auth.getFirstTimeInfraSetupStatus();
|
||||
|
|
@ -23,9 +24,20 @@ const getFirstTimeInfraSetupStatus = async () => {
|
|||
* @param {function} next
|
||||
* @returns {void}
|
||||
*/
|
||||
|
||||
export default <HoppModule>{
|
||||
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();
|
||||
|
||||
// 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;
|
||||
|
||||
if (
|
||||
onboardingStatus?.onboardingCompleted &&
|
||||
!onboardingStatus.canReRunOnboarding &&
|
||||
to.name === 'onboarding'
|
||||
) {
|
||||
// If onboarding is completed, redirect to the dashboard
|
||||
return next({ name: 'index' });
|
||||
}
|
||||
|
||||
// Route Guards
|
||||
if (!isGuestRoute(to.name) && !isAdmin) {
|
||||
/**
|
||||
|
|
|
|||
135
packages/hoppscotch-sh-admin/src/pages/onboarding.vue
Normal file
135
packages/hoppscotch-sh-admin/src/pages/onboarding.vue
Normal 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>
|
||||
|
|
@ -31,6 +31,9 @@
|
|||
<HoppSmartTab :id="'token'" :label="t('configs.tabs.infra_tokens')">
|
||||
<Tokens />
|
||||
</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')">
|
||||
<div class="pb-8 px-4 flex flex-col space-y-8 divide-y divide-divider">
|
||||
<SettingsDataSharing v-model:config="workingConfigs" />
|
||||
|
|
@ -76,7 +79,7 @@ const showSaveChangesModal = ref(false);
|
|||
const initiateServerRestart = ref(false);
|
||||
|
||||
// Tabs
|
||||
type OptionTabs = 'auth' | 'smtp' | 'token' | 'miscellaneous';
|
||||
type OptionTabs = 'auth' | 'smtp' | 'token' | 'miscellaneous' | 'rate-limit';
|
||||
const selectedOptionTab = ref<OptionTabs>('auth');
|
||||
|
||||
// Obtain the current and working configs from the useConfigHandler composable
|
||||
|
|
|
|||
933
pnpm-lock.yaml
933
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue