diff --git a/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts b/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts index 71c35cfa..c3e38ec7 100644 --- a/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts +++ b/packages/hoppscotch-backend/src/infra-config/dto/onboarding.dto.ts @@ -114,6 +114,31 @@ export class SaveOnboardingConfigRequest { @IsOptional() @IsString() [InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_AUTH_TYPE]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_USER]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_ID]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_SECRET]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_REFRESH_TOKEN]: string; + @ApiPropertyOptional() + @IsOptional() + @IsString() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL]: string; } export class SaveOnboardingConfigResponse { @@ -204,4 +229,23 @@ export class GetOnboardingConfigResponse { @ApiProperty() @Expose() [InfraConfigEnum.MAILER_SMTP_IGNORE_TLS]: string; + + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_AUTH_TYPE]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_USER]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_ID]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_SECRET]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_REFRESH_TOKEN]: string; + @ApiProperty() + @Expose() + [InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL]: string; } diff --git a/packages/hoppscotch-backend/src/infra-config/helper.ts b/packages/hoppscotch-backend/src/infra-config/helper.ts index ee583316..b2171817 100644 --- a/packages/hoppscotch-backend/src/infra-config/helper.ts +++ b/packages/hoppscotch-backend/src/infra-config/helper.ts @@ -2,6 +2,7 @@ import { AuthProvider } from 'src/auth/helper'; import { ENV_INVALID_DATA_ENCRYPTION_KEY } from 'src/errors'; import { PrismaService } from 'src/prisma/prisma.service'; import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { SMTPAuthType } from 'src/mailer/helper'; import { decrypt, encrypt } from 'src/utils'; import { randomBytes } from 'crypto'; @@ -243,6 +244,36 @@ export async function getDefaultInfraConfigs(): Promise { value: 'false', isEncrypted: false, }, + { + name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, + value: SMTPAuthType.LOGIN, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_USER, + value: null, + isEncrypted: false, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_ID, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_CLIENT_SECRET, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_REFRESH_TOKEN, + value: null, + isEncrypted: true, + }, + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL, + value: null, + isEncrypted: false, + }, { name: InfraConfigEnum.GOOGLE_CLIENT_ID, value: null, diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts index d46fa15d..64dc6ab9 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.spec.ts @@ -3,6 +3,7 @@ import { PrismaService } from 'src/prisma/prisma.service'; import { InfraConfigService } from './infra-config.service'; import { InfraConfigEnum } from 'src/types/InfraConfig'; import { + INFRA_CONFIG_INVALID_INPUT, INFRA_CONFIG_NOT_FOUND, INFRA_CONFIG_OPERATION_NOT_ALLOWED, INFRA_CONFIG_UPDATE_FAILED, @@ -292,4 +293,58 @@ describe('InfraConfigService', () => { ); }); }); + + describe('validateEnvValues', () => { + describe('MAILER_SMTP_AUTH_TYPE', () => { + it('should accept an empty value (defaults to login at runtime)', () => { + const result = infraConfigService.validateEnvValues([ + { name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, value: '' }, + ]); + expect(result).toEqualRight(true); + }); + + it('should accept a known auth type', () => { + const result = infraConfigService.validateEnvValues([ + { name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, value: 'oauth2' }, + ]); + expect(result).toEqualRight(true); + }); + + it('should reject an unknown auth type', () => { + const result = infraConfigService.validateEnvValues([ + { name: InfraConfigEnum.MAILER_SMTP_AUTH_TYPE, value: 'kerberos' }, + ]); + expect(result).toEqualLeft(INFRA_CONFIG_INVALID_INPUT); + }); + }); + + describe('MAILER_SMTP_OAUTH2_ACCESS_URL', () => { + it('should accept an empty value', () => { + const result = infraConfigService.validateEnvValues([ + { name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL, value: '' }, + ]); + expect(result).toEqualRight(true); + }); + + it('should accept a valid HTTPS URL', () => { + const result = infraConfigService.validateEnvValues([ + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL, + value: 'https://oauth2.googleapis.com/token', + }, + ]); + expect(result).toEqualRight(true); + }); + + it('should reject a malformed URL', () => { + const result = infraConfigService.validateEnvValues([ + { + name: InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL, + value: 'not-a-url', + }, + ]); + expect(result).toEqualLeft(INFRA_CONFIG_INVALID_INPUT); + }); + }); + }); }); diff --git a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts index ce43cd68..fd4ba93f 100644 --- a/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts +++ b/packages/hoppscotch-backend/src/infra-config/infra-config.service.ts @@ -4,6 +4,7 @@ import { PrismaService } from 'src/prisma/prisma.service'; import { InfraConfig as DBInfraConfig } from 'src/generated/prisma/client'; import * as E from 'fp-ts/Either'; import { InfraConfigEnum } from 'src/types/InfraConfig'; +import { SMTPAuthType } from 'src/mailer/helper'; import { AUTH_PROVIDER_NOT_SPECIFIED, DATABASE_TABLE_NOT_EXIST, @@ -736,6 +737,18 @@ export class InfraConfigService implements OnModuleInit, OnModuleDestroy { if (value !== 'true' && value !== 'false') return fail(); break; + case InfraConfigEnum.MAILER_SMTP_AUTH_TYPE: + if ( + value && + !Object.values(SMTPAuthType).includes(value as SMTPAuthType) + ) + return fail(); + break; + + case InfraConfigEnum.MAILER_SMTP_OAUTH2_ACCESS_URL: + if (value && !validateUrl(value)) return fail(); + break; + case InfraConfigEnum.MAILER_SMTP_URL: if (!validateSMTPUrl(value)) return fail(); break; diff --git a/packages/hoppscotch-backend/src/mailer/helper.ts b/packages/hoppscotch-backend/src/mailer/helper.ts index d38dc71b..639312e3 100644 --- a/packages/hoppscotch-backend/src/mailer/helper.ts +++ b/packages/hoppscotch-backend/src/mailer/helper.ts @@ -1,7 +1,13 @@ import { TransportType } from '@nestjs-modules/mailer/dist/interfaces/mailer-options.interface'; +import type SMTPConnection from 'nodemailer/lib/smtp-connection'; import { MAILER_SMTP_URL_UNDEFINED } from 'src/errors'; import { throwErr } from 'src/utils'; +export enum SMTPAuthType { + LOGIN = 'login', + OAUTH2 = 'oauth2', +} + function isSMTPCustomConfigsEnabled(value) { return value === 'true'; } @@ -21,26 +27,52 @@ export function getTransportOption(env): TransportType { } else { console.log('Using advanced mailer configuration'); - const smtpUser = env.INFRA.MAILER_SMTP_USER?.trim() || undefined; - const smtpPass = env.INFRA.MAILER_SMTP_PASSWORD?.trim() || undefined; + const authType = env.INFRA.MAILER_SMTP_AUTH_TYPE?.trim(); - // Both credentials must be provided together or both omitted - const hasUser = !!smtpUser; - const hasPass = !!smtpPass; - if (hasUser !== hasPass) { - throw new Error( - 'SMTP auth requires both MAILER_SMTP_USER and MAILER_SMTP_PASSWORD. Provide both or leave both empty for unauthenticated relay.', - ); + let auth: SMTPConnection.AuthenticationType | undefined; + + if (authType === SMTPAuthType.OAUTH2) { + const oauth2User = env.INFRA.MAILER_SMTP_OAUTH2_USER?.trim() || undefined; + const oauth2ClientId = + env.INFRA.MAILER_SMTP_OAUTH2_CLIENT_ID?.trim() || undefined; + const oauth2ClientSecret = + env.INFRA.MAILER_SMTP_OAUTH2_CLIENT_SECRET?.trim() || undefined; + const oauth2RefreshToken = + env.INFRA.MAILER_SMTP_OAUTH2_REFRESH_TOKEN?.trim() || undefined; + const oauth2AccessUrl = + env.INFRA.MAILER_SMTP_OAUTH2_ACCESS_URL?.trim() || undefined; + + auth = { + type: SMTPAuthType.OAUTH2, + user: oauth2User, + clientId: oauth2ClientId, + clientSecret: oauth2ClientSecret, + refreshToken: oauth2RefreshToken, + accessUrl: oauth2AccessUrl, + }; + } else { + const smtpUser = env.INFRA.MAILER_SMTP_USER?.trim() || undefined; + const smtpPass = env.INFRA.MAILER_SMTP_PASSWORD?.trim() || undefined; + + const hasUser = !!smtpUser; + const hasPass = !!smtpPass; + if (hasUser !== hasPass) { + throw new Error( + 'SMTP auth requires both MAILER_SMTP_USER and MAILER_SMTP_PASSWORD. Provide both or leave both empty for unauthenticated relay.', + ); + } + + auth = + smtpUser && smtpPass + ? { type: SMTPAuthType.LOGIN, user: smtpUser, pass: smtpPass } + : undefined; } - const auth = - smtpUser && smtpPass ? { user: smtpUser, pass: smtpPass } : undefined; - return { host: env.INFRA.MAILER_SMTP_HOST, port: +env.INFRA.MAILER_SMTP_PORT, secure: env.INFRA.MAILER_SMTP_SECURE === 'true', - ...(auth && { auth }), + auth, ignoreTLS: env.INFRA.MAILER_SMTP_IGNORE_TLS === 'true', tls: { rejectUnauthorized: env.INFRA.MAILER_TLS_REJECT_UNAUTHORIZED === 'true', diff --git a/packages/hoppscotch-backend/src/types/InfraConfig.ts b/packages/hoppscotch-backend/src/types/InfraConfig.ts index 5877ffba..26713b7c 100644 --- a/packages/hoppscotch-backend/src/types/InfraConfig.ts +++ b/packages/hoppscotch-backend/src/types/InfraConfig.ts @@ -27,6 +27,13 @@ export enum InfraConfigEnum { MAILER_TLS_REJECT_UNAUTHORIZED = 'MAILER_TLS_REJECT_UNAUTHORIZED', MAILER_SMTP_IGNORE_TLS = 'MAILER_SMTP_IGNORE_TLS', + MAILER_SMTP_AUTH_TYPE = 'MAILER_SMTP_AUTH_TYPE', + MAILER_SMTP_OAUTH2_USER = 'MAILER_SMTP_OAUTH2_USER', + MAILER_SMTP_OAUTH2_CLIENT_ID = 'MAILER_SMTP_OAUTH2_CLIENT_ID', + MAILER_SMTP_OAUTH2_CLIENT_SECRET = 'MAILER_SMTP_OAUTH2_CLIENT_SECRET', + MAILER_SMTP_OAUTH2_REFRESH_TOKEN = 'MAILER_SMTP_OAUTH2_REFRESH_TOKEN', + MAILER_SMTP_OAUTH2_ACCESS_URL = 'MAILER_SMTP_OAUTH2_ACCESS_URL', + GOOGLE_CLIENT_ID = 'GOOGLE_CLIENT_ID', GOOGLE_CLIENT_SECRET = 'GOOGLE_CLIENT_SECRET', GOOGLE_CALLBACK_URL = 'GOOGLE_CALLBACK_URL', diff --git a/packages/hoppscotch-sh-admin/locales/en.json b/packages/hoppscotch-sh-admin/locales/en.json index 15c9d1b0..f3f6b40b 100644 --- a/packages/hoppscotch-sh-admin/locales/en.json +++ b/packages/hoppscotch-sh-admin/locales/en.json @@ -89,6 +89,15 @@ "tls_reject_unauthorized": "TLS Reject Unauthorized", "title": "SMTP Configurations", "smtp_auth_incomplete": "SMTP username and password must both be provided or both left empty", + "auth_switch_description": "Switching will clear the credentials entered in the current authentication type. Any unsaved data will be lost and cannot be undone.", + "auth_type": "Authentication Type", + "auth_type_login": "Basic Auth (Login)", + "auth_type_oauth2": "OAuth 2.0", + "oauth2_user": "OAuth 2.0 User", + "oauth2_client_id": "OAuth 2.0 Client ID", + "oauth2_client_secret": "OAuth 2.0 Client Secret", + "oauth2_refresh_token": "OAuth 2.0 Refresh Token", + "oauth2_access_url": "OAuth 2.0 Access Token URL", "toggle_failure": "Failed to toggle smtp!!", "update_failure": "Failed to update smtp configurations!!", "user": "SMTP User" diff --git a/packages/hoppscotch-sh-admin/src/components.d.ts b/packages/hoppscotch-sh-admin/src/components.d.ts index 27a89ad6..0cfce920 100644 --- a/packages/hoppscotch-sh-admin/src/components.d.ts +++ b/packages/hoppscotch-sh-admin/src/components.d.ts @@ -46,7 +46,9 @@ declare module 'vue' { IconLucideHelpCircle: typeof import('~icons/lucide/help-circle')['default'] IconLucideInbox: typeof import('~icons/lucide/inbox')['default'] IconLucideInfo: typeof import('~icons/lucide/info')['default'] + IconLucideLock: typeof import('~icons/lucide/lock')['default'] IconLucideSearch: typeof import('~icons/lucide/search')['default'] + IconLucideShield: typeof import('~icons/lucide/shield')['default'] IconLucideUser: typeof import('~icons/lucide/user')['default'] OnboardingAuthProviderCard: typeof import('./components/onboarding/AuthProviderCard.vue')['default'] OnboardingAuthSetup: typeof import('./components/onboarding/AuthSetup.vue')['default'] @@ -81,5 +83,6 @@ declare module 'vue' { UsersInviteModal: typeof import('./components/users/InviteModal.vue')['default'] UsersSharedRequests: typeof import('./components/users/SharedRequests.vue')['default'] UsersSuccessInviteModal: typeof import('./components/users/SuccessInviteModal.vue')['default'] + UsersTeams: typeof import('./components/users/Teams.vue')['default'] } } diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue index 257c5c4b..6d8ca185 100644 --- a/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/AuthSetup.vue @@ -172,7 +172,7 @@ const props = defineProps<{ isFirstTimeSetup?: boolean }>(); const emit = defineEmits<{ ( e: 'complete-onboarding', - payload: { submittingConfigs: boolean; summary: OnBoardingSummary } + payload: { submittingConfigs: boolean; summary: OnBoardingSummary }, ): void; }>(); @@ -212,10 +212,10 @@ watch( () => enabledConfigs.value, (configs) => { isOAuthEnabled.value = OAuthProviders.some((provider) => - configs.includes(provider) + configs.includes(provider), ); }, - { immediate: true } + { immediate: true }, ); watch( @@ -229,32 +229,41 @@ watch( selectedOptions.value = ['OAUTH', 'SMTP']; } }, - { immediate: true } + { immediate: true }, ); const isSelected = (option: SelectedOption) => selectedOptions.value.includes(option as any); -const isSmtpEnabled = computed(() => enabledConfigs.value.includes('MAILER')); +const isSmtpEnabled = computed( + () => + enabledConfigs.value.includes('MAILER') || + enabledConfigs.value.includes('EMAIL'), +); const updateOAuthEnabled = () => { isOAuthEnabled.value = OAuthProviders.some((provider) => - enabledConfigs.value.includes(provider) + enabledConfigs.value.includes(provider), ); }; +// Card clicks reconcile enabled state to match the card's selection, rather +// than blindly flipping. When the backend pre-populated enabledConfigs +// (e.g. a prior partial onboarding), a plain toggle would wipe the existing +// EMAIL/GOOGLE entries the moment the user clicks the card to "select" them. const toggleSelectedOption = (option: SelectedOption) => { - if (selectedOptions.value.includes(option as any)) { - selectedOptions.value = selectedOptions.value.filter( - (opt) => opt !== option - ); - } else { - selectedOptions.value.push(option as any); - } + const willBeSelected = !selectedOptions.value.includes(option); + + selectedOptions.value = willBeSelected + ? [...selectedOptions.value, option] + : selectedOptions.value.filter((opt) => opt !== option); + if (option === 'SMTP') { - toggleConfig('EMAIL'); - toggleSmtpConfig(); - } else { + if (willBeSelected !== isSmtpEnabled.value) { + toggleConfig('EMAIL'); + toggleSmtpConfig(); + } + } else if (willBeSelected !== isOAuthEnabled.value) { toggleConfig(option); } }; diff --git a/packages/hoppscotch-sh-admin/src/components/onboarding/OAuthSetup.vue b/packages/hoppscotch-sh-admin/src/components/onboarding/OAuthSetup.vue index 82ed1dd6..f1cc7f03 100644 --- a/packages/hoppscotch-sh-admin/src/components/onboarding/OAuthSetup.vue +++ b/packages/hoppscotch-sh-admin/src/components/onboarding/OAuthSetup.vue @@ -2,7 +2,8 @@
@@ -106,11 +238,13 @@ import { useVModel } from '@vueuse/core'; import { computed, reactive, watch } from 'vue'; import { useI18n } from '~/composables/i18n'; +import { useSmtpAuthTypeSwitch } from '~/composables/useSmtpAuthTypeSwitch'; 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'; +import IconLock from '~icons/lucide/lock'; +import IconShield from '~icons/lucide/shield'; const t = useI18n(); @@ -124,7 +258,6 @@ const emit = defineEmits<{ const workingConfigs = useVModel(props, 'config', emit); -// Get or set smtpConfigs from workingConfigs const smtpConfigs = computed({ get() { return workingConfigs.value?.mailConfigs; @@ -134,43 +267,82 @@ const smtpConfigs = computed({ }, }); -// Mask sensitive fields +type MailFields = ServerConfigs['mailConfigs']['fields']; + +// Extract only the keys whose value type is `string` +type StringFieldKey = { + [K in keyof MailFields]: MailFields[K] extends string ? K : never; +}[keyof MailFields]; + +type BooleanFieldKey = { + [K in keyof MailFields]: MailFields[K] extends boolean ? K : never; +}[keyof MailFields]; + type Field = { name: string; - key: keyof ServerConfigs['mailConfigs']['fields']; + key: StringFieldKey; error?: string; }; -const smtpConfigFields = reactive([ +type CheckboxField = { + name: string; + key: BooleanFieldKey; +}; + +// A typed view of `fields` that only exposes string-valued keys, +// so that `v-model="stringFields[field.key]"` resolves to `string`. +const stringFields = computed( + () => smtpConfigs.value.fields as Pick, +); + +// Basic mode: just the SMTP URL +const basicFields: Field[] = [ { name: t('configs.mail_configs.smtp_url'), key: 'mailer_smtp_url', error: t('configs.mail_configs.input_validation'), }, +]; + +// Connection +const connectionFields: Field[] = [ + { name: t('configs.mail_configs.host'), key: 'mailer_smtp_host' }, + { name: t('configs.mail_configs.port'), key: 'mailer_smtp_port' }, +]; + +// Login auth credentials +const loginAuthFields: Field[] = [ + { name: t('configs.mail_configs.user'), key: 'mailer_smtp_user' }, + { name: t('configs.mail_configs.password'), key: 'mailer_smtp_password' }, +]; + +// OAuth2 auth credentials +const oauth2Fields: Field[] = [ { - name: t('configs.mail_configs.address_from'), - key: 'mailer_from_address', + name: t('configs.mail_configs.oauth2_user'), + key: 'mailer_smtp_oauth2_user', }, { - name: t('configs.mail_configs.host'), - key: 'mailer_smtp_host', + name: t('configs.mail_configs.oauth2_client_id'), + key: 'mailer_smtp_oauth2_client_id', }, { - name: t('configs.mail_configs.port'), - key: 'mailer_smtp_port', + name: t('configs.mail_configs.oauth2_client_secret'), + key: 'mailer_smtp_oauth2_client_secret', }, { - name: t('configs.mail_configs.user'), - key: 'mailer_smtp_user', + name: t('configs.mail_configs.oauth2_refresh_token'), + key: 'mailer_smtp_oauth2_refresh_token', }, { - name: t('configs.mail_configs.password'), - key: 'mailer_smtp_password', - }, - { - name: t('configs.mail_configs.secure'), - key: 'mailer_smtp_secure', + name: t('configs.mail_configs.oauth2_access_url'), + key: 'mailer_smtp_oauth2_access_url', }, +]; + +// Security checkboxes +const securityFields: CheckboxField[] = [ + { name: t('configs.mail_configs.secure'), key: 'mailer_smtp_secure' }, { name: t('configs.mail_configs.ignore_tls'), key: 'mailer_smtp_ignore_tls', @@ -179,7 +351,7 @@ const smtpConfigFields = reactive([ name: t('configs.mail_configs.tls_reject_unauthorized'), key: 'mailer_tls_reject_unauthorized', }, -]); +]; const maskState = reactive>({ mailer_smtp_url: true, @@ -188,6 +360,11 @@ const maskState = reactive>({ mailer_smtp_port: true, mailer_smtp_user: true, mailer_smtp_password: true, + mailer_smtp_oauth2_user: true, + mailer_smtp_oauth2_client_id: true, + mailer_smtp_oauth2_client_secret: true, + mailer_smtp_oauth2_refresh_token: true, + mailer_smtp_oauth2_access_url: true, }); const toggleMask = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) => { @@ -197,41 +374,8 @@ const toggleMask = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) => { const isMasked = (fieldKey: keyof ServerConfigs['mailConfigs']['fields']) => maskState[fieldKey]; -const fieldCondition = (field: Field) => { - const advancedFields = [ - 'mailer_smtp_host', - 'mailer_smtp_port', - 'mailer_smtp_user', - 'mailer_smtp_password', - 'mailer_smtp_secure', - 'mailer_smtp_ignore_tls', - 'mailer_tls_reject_unauthorized', - ]; - const basicFields = ['mailer_smtp_url']; - - if (field.key === 'mailer_from_address') { - return true; - } - - if (smtpConfigs.value.fields.mailer_use_custom_configs) { - return ( - !basicFields.includes(field.key) && advancedFields.includes(field.key) - ); - } else return basicFields.includes(field.key); -}; - -const isCheckboxField = (field: Field) => { - const checkboxKeys = [ - 'mailer_smtp_secure', - 'mailer_smtp_ignore_tls', - 'mailer_tls_reject_unauthorized', - ]; - return checkboxKeys.includes(field.key); -}; - -const toggleCheckbox = (field: Field) => - ((smtpConfigs.value.fields[field.key] as boolean) = - !smtpConfigs.value.fields[field.key]); +const toggleCheckbox = (field: CheckboxField) => + (smtpConfigs.value.fields[field.key] = !smtpConfigs.value.fields[field.key]); // Input Validation const fieldErrors = computed(() => { @@ -246,11 +390,29 @@ const fieldErrors = computed(() => { return errors; }); -const getFieldError = ( - fieldKey: keyof ServerConfigs['mailConfigs']['fields'] -) => fieldErrors.value[fieldKey]; +const getFieldError = (fieldKey: StringFieldKey) => fieldErrors.value[fieldKey]; watch(fieldErrors, (errors) => { hasInputValidationFailed.value = Object.values(errors).some(Boolean); }); + +const LOGIN_KEYS: StringFieldKey[] = [ + 'mailer_smtp_user', + 'mailer_smtp_password', +]; +const OAUTH2_KEYS: StringFieldKey[] = [ + 'mailer_smtp_oauth2_user', + 'mailer_smtp_oauth2_client_id', + 'mailer_smtp_oauth2_client_secret', + 'mailer_smtp_oauth2_refresh_token', + 'mailer_smtp_oauth2_access_url', +]; + +const { authType, showAuthSwitchModal, confirmAuthSwitch, cancelAuthSwitch } = + useSmtpAuthTypeSwitch( + () => stringFields.value, + 'mailer_smtp_auth_type', + LOGIN_KEYS, + OAUTH2_KEYS, + ); diff --git a/packages/hoppscotch-sh-admin/src/components/ui/Accordion.vue b/packages/hoppscotch-sh-admin/src/components/ui/Accordion.vue index 75484a45..9b5c6305 100644 --- a/packages/hoppscotch-sh-admin/src/components/ui/Accordion.vue +++ b/packages/hoppscotch-sh-admin/src/components/ui/Accordion.vue @@ -41,7 +41,7 @@ diff --git a/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts index 6c42afe2..1eda5199 100644 --- a/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts +++ b/packages/hoppscotch-sh-admin/src/composables/useConfigHandler.ts @@ -144,6 +144,23 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) { 'true', mailer_use_custom_configs: getFieldValue(InfraConfigEnum.MailerUseCustomConfigs) === 'true', + mailer_smtp_auth_type: + getFieldValue(InfraConfigEnum.MailerSmtpAuthType) || 'login', + mailer_smtp_oauth2_user: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2User + ), + mailer_smtp_oauth2_client_id: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2ClientId + ), + mailer_smtp_oauth2_client_secret: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2ClientSecret + ), + mailer_smtp_oauth2_refresh_token: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2RefreshToken + ), + mailer_smtp_oauth2_access_url: getFieldValue( + InfraConfigEnum.MailerSmtpOauth2AccessUrl + ), }, }, tokenConfigs: { @@ -277,13 +294,23 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) { // SMTP user and password are optional as a pair (both or neither) const optionalMailerKeys = ['mailer_smtp_user', 'mailer_smtp_password']; + // OAuth2 fields are always optional and auth_type has a default + const oauth2Keys = [ + 'mailer_smtp_auth_type', + 'mailer_smtp_oauth2_user', + 'mailer_smtp_oauth2_client_id', + 'mailer_smtp_oauth2_client_secret', + 'mailer_smtp_oauth2_refresh_token', + 'mailer_smtp_oauth2_access_url', + ]; const excludeKeys = mailer_use_custom_configs - ? ['mailer_smtp_url', ...optionalMailerKeys] + ? ['mailer_smtp_url', ...optionalMailerKeys, ...oauth2Keys] : [ 'mailer_smtp_host', 'mailer_smtp_port', 'mailer_smtp_user', 'mailer_smtp_password', + ...oauth2Keys, ]; return ( @@ -323,6 +350,10 @@ export function useConfigHandler(updatedConfigs?: ServerConfigs) { const fields = config.mailConfigs.fields; if (!fields.mailer_use_custom_configs) return false; + // Enforced regardless of auth_type: the backend validates the pair + // on every save, so stale login values left behind after switching + // to the OAuth2 tab would still be rejected. Surface this in the FE + // toast so users know to clear those fields before saving. const hasUser = fields.mailer_smtp_user.trim() !== ''; const hasPass = fields.mailer_smtp_password.trim() !== ''; diff --git a/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts index 0ac0bf26..542c6ef4 100644 --- a/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts +++ b/packages/hoppscotch-sh-admin/src/composables/useOnboardingConfigHandler.ts @@ -31,7 +31,13 @@ export type MailerConfigKeys = | 'SMTP_USER' | 'SMTP_PASSWORD' | 'SMTP_IGNORE_TLS' - | 'TLS_REJECT_UNAUTHORIZED'; + | 'TLS_REJECT_UNAUTHORIZED' + | 'SMTP_AUTH_TYPE' + | 'SMTP_OAUTH2_USER' + | 'SMTP_OAUTH2_CLIENT_ID' + | 'SMTP_OAUTH2_CLIENT_SECRET' + | 'SMTP_OAUTH2_REFRESH_TOKEN' + | 'SMTP_OAUTH2_ACCESS_URL'; export type Configs = { oAuthProviders: { @@ -52,7 +58,7 @@ export type OnBoardingSummary = { }; function mapOAuthProviders( - configs: Partial> + configs: Partial>, ): Configs['oAuthProviders'] { return { GOOGLE: { @@ -78,7 +84,7 @@ function mapOAuthProviders( } function mapMailerConfigs( - configs: Partial> + configs: Partial>, ): Configs['mailerConfigs'] { return { MAILER_SMTP_ENABLE: configs.MAILER_SMTP_ENABLE ?? '', @@ -93,6 +99,14 @@ function mapMailerConfigs( MAILER_SMTP_IGNORE_TLS: configs.MAILER_SMTP_IGNORE_TLS || 'false', MAILER_TLS_REJECT_UNAUTHORIZED: configs.MAILER_TLS_REJECT_UNAUTHORIZED || 'false', + MAILER_SMTP_AUTH_TYPE: configs.MAILER_SMTP_AUTH_TYPE || 'login', + MAILER_SMTP_OAUTH2_USER: configs.MAILER_SMTP_OAUTH2_USER ?? '', + MAILER_SMTP_OAUTH2_CLIENT_ID: configs.MAILER_SMTP_OAUTH2_CLIENT_ID ?? '', + MAILER_SMTP_OAUTH2_CLIENT_SECRET: + configs.MAILER_SMTP_OAUTH2_CLIENT_SECRET ?? '', + MAILER_SMTP_OAUTH2_REFRESH_TOKEN: + configs.MAILER_SMTP_OAUTH2_REFRESH_TOKEN ?? '', + MAILER_SMTP_OAUTH2_ACCESS_URL: configs.MAILER_SMTP_OAUTH2_ACCESS_URL ?? '', }; } @@ -132,7 +146,7 @@ export function useOnboardingConfigHandler() { const toggleConfig = (key: EnabledConfig | 'OAUTH' | 'EMAIL') => { if (key === 'OAUTH') { enabledConfigs.value = enabledConfigs.value.filter( - (c) => !['GOOGLE', 'GITHUB', 'MICROSOFT'].includes(c) + (c) => !['GOOGLE', 'GITHUB', 'MICROSOFT'].includes(c), ); } @@ -140,7 +154,7 @@ export function useOnboardingConfigHandler() { const hasEmail = enabledConfigs.value.includes('EMAIL'); const hasMailer = enabledConfigs.value.includes('MAILER'); enabledConfigs.value = enabledConfigs.value.filter( - (c) => c !== 'EMAIL' && c !== 'MAILER' + (c) => c !== 'EMAIL' && c !== 'MAILER', ); if (!hasEmail || !hasMailer) { enabledConfigs.value.push('EMAIL', 'MAILER'); @@ -234,6 +248,12 @@ export function useOnboardingConfigHandler() { 'MAILER_SMTP_IGNORE_TLS', 'MAILER_TLS_REJECT_UNAUTHORIZED', 'MAILER_SMTP_ENABLE', + 'MAILER_SMTP_AUTH_TYPE', + 'MAILER_SMTP_OAUTH2_USER', + 'MAILER_SMTP_OAUTH2_CLIENT_ID', + 'MAILER_SMTP_OAUTH2_CLIENT_SECRET', + 'MAILER_SMTP_OAUTH2_REFRESH_TOKEN', + 'MAILER_SMTP_OAUTH2_ACCESS_URL', ].includes(key); }); }; @@ -252,7 +272,7 @@ export function useOnboardingConfigHandler() { } const relevantKeys = Object.keys(configs).filter((key) => - enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig) + enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig), ); const neededKeys = filterNeededConfigs(relevantKeys); @@ -263,9 +283,14 @@ export function useOnboardingConfigHandler() { const optionalSmtpKeys = new Set([ 'MAILER_SMTP_USER', 'MAILER_SMTP_PASSWORD', + 'MAILER_SMTP_OAUTH2_USER', + 'MAILER_SMTP_OAUTH2_CLIENT_ID', + 'MAILER_SMTP_OAUTH2_CLIENT_SECRET', + 'MAILER_SMTP_OAUTH2_REFRESH_TOKEN', + 'MAILER_SMTP_OAUTH2_ACCESS_URL', ]); const allFilled = neededKeys.every( - (key) => configs[key] || optionalSmtpKeys.has(key) + (key) => configs[key] || optionalSmtpKeys.has(key), ); if (!allFilled) { @@ -274,14 +299,17 @@ export function useOnboardingConfigHandler() { toast.error( t('onboarding.please_fill_configurations', { fieldName: makeReadableKey(key), - }) + }), ); }); return; } // SMTP credentials must be provided together or both left empty. - // Only enforce when custom SMTP mode is active (not simple URL mode). + // Enforced regardless of auth_type: the backend validates the pair on + // every save, so stale login values left behind after switching to the + // OAuth2 tab would still be rejected. Surface this in the FE toast so + // users know to clear those fields before saving. if ( enabledConfigs.value.includes('MAILER') && configs['MAILER_USE_CUSTOM_CONFIGS'] === 'true' @@ -301,8 +329,8 @@ export function useOnboardingConfigHandler() { Object.entries(configs).filter( ([key, val]) => enabledConfigs.value.includes(key.split('_')[0] as EnabledConfig) && - (val || optionalSmtpKeys.has(key)) - ) + (val || optionalSmtpKeys.has(key)), + ), ); }; @@ -331,7 +359,7 @@ export function useOnboardingConfigHandler() { } const filteredEnabledConfigs = enabledConfigs.value.filter( - (config) => config !== 'OAUTH' && config !== 'MAILER' + (config) => config !== 'OAUTH' && config !== 'MAILER', ); const configWithAuth = { @@ -371,7 +399,22 @@ export function useOnboardingConfigHandler() { const allowed = configs[InfraConfigEnum.ViteAllowedAuthProviders]; if (allowed) { - enabledConfigs.value = allowed.split(',') as EnabledConfig[]; + // Trim each entry so whitespace variations ("GOOGLE, EMAIL") don't + // cause provider-name mismatches in downstream `.includes()` checks. + const parsed = allowed + .split(',') + .map((p) => p.trim()) + .filter(Boolean) as EnabledConfig[]; + + // The backend persists only 'EMAIL' in VITE_ALLOWED_AUTH_PROVIDERS, + // but internally we also track 'MAILER' as the signal that MAILER_* + // keys should be kept on save. toggleConfig('EMAIL') pairs them, so + // mirror that invariant on load to keep the two flags in sync. + if (parsed.includes('EMAIL') && !parsed.includes('MAILER')) { + parsed.push('MAILER'); + } + + enabledConfigs.value = parsed; } currentConfigs.value = { @@ -404,7 +447,7 @@ export function useOnboardingConfigHandler() { enableConfig('EMAIL'); } }, - { deep: true, immediate: true } + { deep: true, immediate: true }, ); return { diff --git a/packages/hoppscotch-sh-admin/src/composables/useSmtpAuthTypeSwitch.ts b/packages/hoppscotch-sh-admin/src/composables/useSmtpAuthTypeSwitch.ts new file mode 100644 index 00000000..b0f65f58 --- /dev/null +++ b/packages/hoppscotch-sh-admin/src/composables/useSmtpAuthTypeSwitch.ts @@ -0,0 +1,63 @@ +import { computed, ref } from 'vue'; + +// Shared auth-type tab switching logic for SMTP configuration UIs. +// When switching between login/oauth2 tabs, if the current tab has data +// we confirm before clearing — so stale credentials don't get persisted +// alongside the active set. +// +// `fields` is a lazy accessor to the reactive record (the two call sites +// nest it differently: `currentConfigs.mailerConfigs` vs `smtpConfigs.fields`). +// Calling it inside computed getters/setters preserves reactivity. +export function useSmtpAuthTypeSwitch( + fields: () => Record, + authKey: K, + loginKeys: readonly K[], + oauth2Keys: readonly K[], +) { + const showAuthSwitchModal = ref(false); + const pendingAuthType = ref(null); + + const hasAnyValue = (keys: readonly K[]) => + keys.some((k) => (fields()[k] ?? '').trim() !== ''); + + const authType = computed({ + get: () => fields()[authKey], + set: (next: string) => { + const current = fields()[authKey]; + if (next === current) return; + + const keysToClear = current === 'oauth2' ? oauth2Keys : loginKeys; + if (hasAnyValue(keysToClear)) { + pendingAuthType.value = next; + showAuthSwitchModal.value = true; + return; + } + + fields()[authKey] = next; + }, + }); + + const confirmAuthSwitch = () => { + if (pendingAuthType.value === null) return; + const current = fields()[authKey]; + const keysToClear = current === 'oauth2' ? oauth2Keys : loginKeys; + keysToClear.forEach((k) => { + fields()[k] = ''; + }); + fields()[authKey] = pendingAuthType.value; + pendingAuthType.value = null; + showAuthSwitchModal.value = false; + }; + + const cancelAuthSwitch = () => { + pendingAuthType.value = null; + showAuthSwitchModal.value = false; + }; + + return { + authType, + showAuthSwitchModal, + confirmAuthSwitch, + cancelAuthSwitch, + }; +} diff --git a/packages/hoppscotch-sh-admin/src/helpers/configs.ts b/packages/hoppscotch-sh-admin/src/helpers/configs.ts index 4b915a02..14f51204 100644 --- a/packages/hoppscotch-sh-admin/src/helpers/configs.ts +++ b/packages/hoppscotch-sh-admin/src/helpers/configs.ts @@ -56,6 +56,12 @@ export type ServerConfigs = { mailer_smtp_ignore_tls: boolean; mailer_tls_reject_unauthorized: boolean; mailer_use_custom_configs: boolean; + mailer_smtp_auth_type: string; + mailer_smtp_oauth2_user: string; + mailer_smtp_oauth2_client_id: string; + mailer_smtp_oauth2_client_secret: string; + mailer_smtp_oauth2_refresh_token: string; + mailer_smtp_oauth2_access_url: string; }; }; @@ -230,6 +236,30 @@ export const CUSTOM_MAIL_CONFIGS: Config[] = [ name: InfraConfigEnum.MailerTlsRejectUnauthorized, key: 'mailer_tls_reject_unauthorized', }, + { + name: InfraConfigEnum.MailerSmtpAuthType, + key: 'mailer_smtp_auth_type', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2User, + key: 'mailer_smtp_oauth2_user', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2ClientId, + key: 'mailer_smtp_oauth2_client_id', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2ClientSecret, + key: 'mailer_smtp_oauth2_client_secret', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2RefreshToken, + key: 'mailer_smtp_oauth2_refresh_token', + }, + { + name: InfraConfigEnum.MailerSmtpOauth2AccessUrl, + key: 'mailer_smtp_oauth2_access_url', + }, ]; const DATA_SHARING_CONFIGS: Omit[] = [