diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 00000000..45735c91 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,91 @@ +name: Build and Push Docker Images + +on: + push: + branches: ["main"] + workflow_dispatch: + +jobs: + paths-filter: + runs-on: ubuntu-latest + outputs: + frontend: ${{ steps.filter.outputs.frontend }} + api: ${{ steps.filter.outputs.api }} + widget: ${{ steps.filter.outputs.widget }} + nlu: ${{ steps.filter.outputs.nlu }} + steps: + - uses: actions/checkout@v4 + - name: Filter paths + id: filter + uses: dorny/paths-filter@v3 + with: + filters: | + frontend: + - 'frontend/**' + api: + - 'api/**' + widget: + - 'widget/**' + nlu: + - 'nlu/**' + + build-and-push: + runs-on: ubuntu-latest + needs: + - paths-filter + steps: + - name: Check out repository code + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + id: docker_login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Frontend Docker image + if: ${{ needs.paths-filter.outputs.frontend == 'true' }} + uses: docker/build-push-action@v6 + with: + context: ./ + file: ./frontend/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: hexastack/hexabot-ui:latest + + - name: Build and push API Docker image + if: ${{ needs.paths-filter.outputs.api == 'true' }} + uses: docker/build-push-action@v6 + with: + context: ./api/ + file: ./api/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: hexastack/hexabot-api:latest + + - name: Build and push Widget Docker image + if: ${{ needs.paths-filter.outputs.widget == 'true' }} + uses: docker/build-push-action@v6 + with: + context: ./widget/ + file: ./widget/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: hexastack/hexabot-widget:latest + + - name: Build and push NLU Docker image + if: ${{ needs.paths-filter.outputs.nlu == 'true' }} + uses: docker/build-push-action@v6 + with: + context: ./nlu/ + file: ./nlu/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: hexastack/hexabot-nlu:latest diff --git a/Makefile b/Makefile index 9e7ba3be..c7b635a4 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,4 @@ -# Default path setup -COMPOSE_FILES := -f ./docker/docker-compose.yml -f ./docker/docker-compose.dev.yml +COMPOSE_FILES := -f ./docker/docker-compose.yml # Function to add service files define add_service @@ -8,20 +7,29 @@ define add_service ifneq ($(wildcard ./docker/docker-compose.$(1).prod.yml),) COMPOSE_FILES += -f ./docker/docker-compose.$(1).prod.yml endif - else - COMPOSE_FILES += -f ./docker/docker-compose.$(1).yml -f ./docker/docker-compose.$(1).dev.yml + else ifeq ($(DEV_MODE), true) + COMPOSE_FILES += -f ./docker/docker-compose.$(1).yml + ifneq ($(wildcard ./docker/docker-compose.$(1).dev.yml),) + COMPOSE_FILES += -f ./docker/docker-compose.$(1).dev.yml + endif endif endef -# Check if services are specified and add corresponding compose files -ifneq ($(NGINX),) - $(eval $(call add_service,nginx)) -endif - -ifneq ($(NLU),) - $(eval $(call add_service,nlu)) -endif +# Function to set up COMPOSE_FILES +define compose_files + ifeq ($(1), true) + ifneq ($(wildcard ./docker/docker-compose.dev.yml),) + COMPOSE_FILES += -f ./docker/docker-compose.dev.yml + endif + endif + ifneq ($(NGINX),) + $(eval $(call add_service,nginx)) + endif + ifneq ($(NLU),) + $(eval $(call add_service,nlu)) + endif +endef # Ensure .env file exists and matches .env.example check-env: @@ -36,16 +44,21 @@ init: cp ./docker/.env.example ./docker/.env dev: check-env + $(eval $(call compose_files,true)) docker compose $(COMPOSE_FILES) up -d start: check-env - docker compose $(COMPOSE_FILES) up -d --build + $(eval $(call compose_files,false)) + docker compose $(COMPOSE_FILES) up -d stop: check-env + $(eval $(call compose_files,true)) docker compose $(COMPOSE_FILES) down destroy: check-env + $(eval $(call compose_files,true)) docker compose $(COMPOSE_FILES) down -v migrate-up: - docker-compose $(COMPOSE_FILES) up --no-deps -d database-init \ No newline at end of file + $(eval $(call compose_files,false)) + docker-compose $(COMPOSE_FILES) up --no-deps -d database-init diff --git a/README.md b/README.md index f44af489..48681c05 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Hexabot + ![App Screenshot](https://www.hexabot.ai/assets/images/screencast.gif) ## Description @@ -20,6 +21,7 @@ - **Inbox & Handover:** Provides a real-time chat window where conversations can be monitored and handed over to human agents when necessary. ## Directory Structure + - **frontend:** The admin panel built with React/Next.js for managing chatbot configurations and flows. - **api:** The backend API built with NestJS and connected to MongoDB for data storage and management. - **widget:** A React-based live chat widget that can be embedded into any website to provide real-time interaction. @@ -27,6 +29,7 @@ - **docker:** A set of Docker Compose files for deploying the entire solution, making it easy to run Hexabot in any environment. ## Prerequisites + To ensure Hexabot runs smoothly, you'll need the following: - **Docker:** We recommend using Docker to start the app since multiple services are required (MongoDB, Redis, Prometheus, etc.). All the necessary Docker Compose files are located in the docker folder. @@ -35,28 +38,37 @@ To ensure Hexabot runs smoothly, you'll need the following: ## Installation 1. **Clone the Repository:** + ```bash $ git clone https://github.com/hexastack/hexabot.git ``` + 2. **Environment Setup:** To configure the environment variables, use the Makefile at the root folder for initialization: + ```bash $ make init ``` + This will copy the `.env.example` file to `.env` in the `./docker` directory if the file does not already exist. 3. **Running the Application:** Once your environment is set up, you can start the app. Use either of the following commands: + ```bash $ make start ``` + or for development mode: + ```bash $ make dev ``` + **Note:** The first time you run the app, Docker will take some time to download all the required images. ## Usage -UI Admin Panel is accessible via http://localhost:8080, the default credentials are : +UI Admin Panel is accessible via http://localhost:8080, the default credentials are : + - **Username:** admin@admin.admin - **Password:** adminadmin @@ -65,18 +77,20 @@ Live Chat Widget is accessible via http://localhost:5173 ## Commands - `make init` : Copies the .env.example file to .env in the ./docker directory if .env does not exist. This is usually used for initial setup. -- `make dev` : Starts all configured Docker services in development mode. It first checks the .env file for completeness against .env.example. -- `make start` : Similar to dev, but explicitly builds the Docker images before starting the services. This target also checks the .env file for required variables. +- `make dev` : Starts all configured Docker services in development mode. It first checks the .env file for completeness against .env.example and builds the docker images locally. +- `make start` : Starts all configured Docker services by loading all images from Docker Hub. This target also checks the .env file for required variables. - `make stop` : Stops all running Docker services defined in the compose files. - `make destroy` : Stops all services and removes all volumes associated with the Docker compose setup, ensuring a clean state. - `make check-env` : Checks if the ./docker/.env file exists and contains all the necessary environment variables as defined in ./docker/.env.example. If the file does not exist, it is created from the example. It also lists missing variables if any. -Example on how to start the stack by adding the Nginx service : +Example on how to start the stack by adding the Nginx service : + ```sh make start NGINX=1 ``` ## Documentation + For detailed information on how to get started, as well as in-depth user and developer guides, please refer to our full documentation available in the docs folder or visit the [Documentation](https://docs.hexabot.ai). You can also find specific documentation for different components of the project in the following locations: @@ -86,16 +100,17 @@ You can also find specific documentation for different components of the project - [Live Chat Widget Documentation](widget/README.md) - [NLU Engine Documentation](nlu/README.md) -## Contributing +## Contributing + We welcome contributions from the community! Whether you want to report a bug, suggest new features, or submit a pull request, your input is valuable to us. Please refer to our contribution policy first : [How to contribute to Hexabot](./CONTRIBUTING.md) [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](./CODE_OF_CONDUCT.md) - Feel free to join us on [Discord](https://discord.gg/rNb9t2MFkG) ## License + This software is licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms: 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission. diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 5ee186b7..53f6ef07 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -57,24 +57,28 @@ const i18nOptions: I18nOptions = { @Module({ imports: [ - MailerModule.forRoot({ - transport: new SMTPTransport({ - ...config.emails.smtp, - logger: true, - }), - template: { - adapter: new MjmlAdapter('ejs', { inlineCssEnabled: false }), - dir: './src/templates', - options: { - context: { - appName: config.parameters.appName, - appUrl: config.parameters.appUrl, - // TODO: add i18n support - }, - }, - }, - defaults: { from: config.parameters.email.main }, - }), + ...(config.emails.isEnabled + ? [ + MailerModule.forRoot({ + transport: new SMTPTransport({ + ...config.emails.smtp, + logger: true, + debug: false, + }), + template: { + adapter: new MjmlAdapter('ejs', { inlineCssEnabled: false }), + dir: './src/templates', + options: { + context: { + appName: config.parameters.appName, + appUrl: config.parameters.appUrl, + }, + }, + }, + defaults: { from: config.emails.from }, + }), + ] + : []), MongooseModule.forRoot(config.mongo.uri, { dbName: config.mongo.dbName, connectionFactory: (connection) => { diff --git a/api/src/app.service.ts b/api/src/app.service.ts index 09a07e5e..a78e0d3d 100644 --- a/api/src/app.service.ts +++ b/api/src/app.service.ts @@ -8,19 +8,14 @@ */ import { Injectable } from '@nestjs/common'; -import { EventEmitter2 } from '@nestjs/event-emitter'; import { ExtendedI18nService } from './extended-i18n.service'; @Injectable() export class AppService { - constructor( - private readonly i18n: ExtendedI18nService, - private readonly eventEmitter: EventEmitter2, - ) {} + constructor(private readonly i18n: ExtendedI18nService) {} getHello(): string { - this.eventEmitter.emit('hook:i18n:refresh', []); - return this.i18n.t('Welcome'); + return this.i18n.t('welcome', { lang: 'en' }); } } diff --git a/api/src/config/i18n/en/messages.json b/api/src/config/i18n/en/messages.json index efb9d1cd..c9ecb497 100644 --- a/api/src/config/i18n/en/messages.json +++ b/api/src/config/i18n/en/messages.json @@ -1,18 +1,21 @@ { + "invitation_subject": "[Hexabot] Sign-Up Invitation", + "account_confirmation_subject": "[Hexabot] Account Confirmation", + "password_reset_subject": "[Hexabot] Password Reset", "welcome": "Welcome", "hi": "Hi", "invitation_to_join": "Invitation to join", "invitation_for_account_creation": "You have been invited to create a", "account": "account", - "create_account:": "Click on the button below to create your account:", + "create_account": "Click on the button below to create your account:", "registration_failed": "Registration failed! Either email address or invitation token is invalid.", "create_account_button": "Click on the button below to create your account", "join": "Join", "account_successfully_created_confirm_password": "You have successfully created an account but you will need to confirm your email address.", - "confirm_account:": "Click on the button below in order to confirm your account:", + "confirm_account": "Click on the button below in order to confirm your account:", "password_reset_request": "A request to reset the password for your account has been made.", "reset": "Reset", - "click_to_reset_password:": "Click on the button below in order to set a new password:", + "click_to_reset_password": "Click on the button below in order to set a new password:", "confirm": "Confirm", "best_regards": "Best Regards," } diff --git a/api/src/config/i18n/fr/messages.json b/api/src/config/i18n/fr/messages.json index 5280a1fa..5a09570f 100644 --- a/api/src/config/i18n/fr/messages.json +++ b/api/src/config/i18n/fr/messages.json @@ -1,4 +1,7 @@ { + "invitation_subject": "[Hexabot] Invitation à s'inscrire", + "account_confirmation_subject": "[Hexabot] Confirmation de compte", + "password_reset_subject": "[Hexabot] Réinitialisation du mot de passe", "welcome": "Bienvenue", "hi": "Bonjour", "invitation_to_join": "Invitation", diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 98d541c5..3b51ff4e 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -13,7 +13,7 @@ import { Config } from './types'; export const config: Config = { i18n: { - translationFilename: process.env.I18N_TRANSLATION_FILENAME || '', + translationFilename: process.env.I18N_TRANSLATION_FILENAME || 'messages', }, appPath: process.cwd(), apiPath: process.env.API_ORIGIN, @@ -77,7 +77,7 @@ export const config: Config = { : [undefined], // ['http://example.com', 'https://example.com'], }, session: { - secret: process.env.SESSION_SECRET || '4fac3596aeb0d048e7b6b38235c29248', + secret: process.env.SESSION_SECRET || 'changeme', name: process.env.SESSION_NAME || 'hex.sid', adapter: 'connect-mongo', url: 'mongodb://localhost:27017/hexabot', @@ -90,23 +90,18 @@ export const config: Config = { }, }, emails: { + isEnabled: process.env.EMAIL_SMTP_ENABLED === 'true' || false, smtp: { port: parseInt(process.env.EMAIL_SMTP_PORT) || 25, - host: process.env.EMAIL_SMTP_HOST || 'smtp.mailgun.org', + host: process.env.EMAIL_SMTP_HOST || 'localhost', + ignoreTLS: false, secure: process.env.EMAIL_SMTP_SECURE === 'true' || false, auth: { - user: - process.env.EMAIL_SMTP_USER || - 'postmaster@sandbox9471202ff10448c7ac917618fe94d8e1.mailgun.org', - pass: process.env.EMAIL_SMTP_PASS || 'e58526b30ad640394b5c77a211a19c5b', + user: process.env.EMAIL_SMTP_USER || '', + pass: process.env.EMAIL_SMTP_PASS || '', }, }, - }, - datastores: { - default: { - adapter: 'sails-mongo', - url: 'mongodb://localhost:27017/hexabot', - }, + from: process.env.EMAIL_SMTP_FROM || 'noreply@example.com', }, parameters: { uploadDir: @@ -117,17 +112,9 @@ export const config: Config = { maxUploadSize: process.env.UPLOAD_MAX_SIZE_IN_BYTES ? Number(process.env.UPLOAD_MAX_SIZE_IN_BYTES) : 2000000, - transport: 'smtp', - email: { - main: 'postmaster@sandbox9471202ff10448c7ac917618fe94d8e1.mailgun.org', - }, appName: 'Hexabot.ai', apiUrl: 'http://localhost:4000', appUrl: 'http://localhost:8081', - geocoder: { - provider: 'opencage', - apiKey: 'c2a490d593b14612aefa6ec2e6b77c47', - }, }, pagination: { limit: 10, @@ -135,7 +122,7 @@ export const config: Config = { chatbot: { lang: { default: 'en', - available: ['en', 'fr', 'ar', 'tn'], + available: ['en', 'fr'], }, messages: { track_delivery: false, diff --git a/api/src/config/types.ts b/api/src/config/types.ts index 80150cec..971a1be4 100644 --- a/api/src/config/types.ts +++ b/api/src/config/types.ts @@ -7,6 +7,7 @@ * 3. SaaS Restriction: This software, or any derivative of it, may not be used to offer a competing product or service (SaaS) without prior written consent from Hexastack. Offering the software as a service or using it in a commercial cloud environment without express permission is strictly prohibited. */ +import SMTPConnection from 'nodemailer/lib/smtp-connection'; import type { ServerOptions, Socket } from 'socket.io'; type TJwtOptions = { @@ -69,38 +70,18 @@ export type Config = { }; }; emails: { - smtp: { - port: number; - host: string; - secure: boolean; - auth: { - user: string; - pass: string; - }; - }; - }; - datastores: { - default: { - adapter: string; - url: string; - }; + isEnabled: boolean; + smtp: Partial; + from: string; }; parameters: { uploadDir: string; avatarDir: string; storageMode: 'disk' | 'memory'; maxUploadSize: number; - transport: string; - email: { - main: string; - }; appName: string; apiUrl: string; appUrl: string; - geocoder: { - provider: string; - apiKey: string; - }; }; pagination: { limit: number; diff --git a/api/src/extended-i18n.service.ts b/api/src/extended-i18n.service.ts index 64b7a1c7..5bb4e3d7 100644 --- a/api/src/extended-i18n.service.ts +++ b/api/src/extended-i18n.service.ts @@ -58,9 +58,11 @@ export class ExtendedI18nService< initDynamicTranslations(translations: Translation[]) { this.dynamicTranslations = translations.reduce((acc, curr) => { const { str, translations } = curr; - Object.entries(translations).forEach(([lang, t]) => { - acc[lang][str] = t; - }); + Object.entries(translations) + .filter(([lang]) => lang in acc) + .forEach(([lang, t]) => { + acc[lang][str] = t; + }); return acc; }, this.dynamicTranslations); diff --git a/api/src/setting/schemas/types.ts b/api/src/setting/schemas/types.ts index f81c7352..f13f849e 100644 --- a/api/src/setting/schemas/types.ts +++ b/api/src/setting/schemas/types.ts @@ -111,10 +111,4 @@ export type Settings = { fallback_message: string[]; fallback_block: string; }; - email_settings: { - mailer: string; - auth_user: string; - auth_pass: string; - from: string; - }; } & Record; diff --git a/api/src/setting/seeds/setting.seed-model.ts b/api/src/setting/seeds/setting.seed-model.ts index 3103685a..855aafa6 100644 --- a/api/src/setting/seeds/setting.seed-model.ts +++ b/api/src/setting/seeds/setting.seed-model.ts @@ -99,56 +99,6 @@ export const settingModels: SettingCreateDto[] = [ }, weight: 6, }, - { - group: 'email_settings', - label: 'from', - value: 'no-reply@domain.com', - type: SettingType.text, - weight: 1, - }, - { - group: 'email_settings', - label: 'mailer', - value: 'sendmail', - options: ['sendmail', 'smtp'], - type: SettingType.select, - weight: 2, - }, - { - group: 'email_settings', - label: 'host', - value: 'localhost', - type: SettingType.text, - weight: 3, - }, - { - group: 'email_settings', - label: 'port', - value: '25', - type: SettingType.text, - weight: 4, - }, - { - group: 'email_settings', - label: 'secure', - value: true, - type: SettingType.checkbox, - weight: 5, - }, - { - group: 'email_settings', - label: 'auth_user', - value: '', - type: SettingType.text, - weight: 6, - }, - { - group: 'email_settings', - label: 'auth_pass', - value: '', - type: SettingType.text, - weight: 7, - }, { group: 'contact', label: 'contact_email_recipient', diff --git a/api/src/templates/account_confirmation.mjml b/api/src/templates/account_confirmation.mjml index dbb9f89c..cf10ff87 100644 --- a/api/src/templates/account_confirmation.mjml +++ b/api/src/templates/account_confirmation.mjml @@ -24,10 +24,10 @@ - <%= t('best_regards') %>,<%= t('best_regards') %> - <%= this.appName %> diff --git a/api/src/templates/invitation.mjml b/api/src/templates/invitation.mjml index 197d78d4..e608bc0d 100644 --- a/api/src/templates/invitation.mjml +++ b/api/src/templates/invitation.mjml @@ -26,11 +26,11 @@ - <%= t('best_regards') %> - <%= this.appName %> diff --git a/api/src/templates/password_reset.mjml b/api/src/templates/password_reset.mjml index 0ea9794d..34975029 100644 --- a/api/src/templates/password_reset.mjml +++ b/api/src/templates/password_reset.mjml @@ -23,10 +23,10 @@ - <%= t('best_regards') %>,<%= t('best_regards') %> - <%= this.appName %> diff --git a/api/src/user/services/invitation.service.ts b/api/src/user/services/invitation.service.ts index db0c8a71..84bc02cc 100644 --- a/api/src/user/services/invitation.service.ts +++ b/api/src/user/services/invitation.service.ts @@ -11,6 +11,7 @@ import { Inject, Injectable, InternalServerErrorException, + Optional, } from '@nestjs/common'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; import { MailerService } from '@nestjs-modules/mailer'; @@ -32,7 +33,7 @@ export class InvitationService extends BaseService { @Inject(InvitationRepository) readonly repository: InvitationRepository, @Inject(JwtService) private readonly jwtService: JwtService, - private readonly mailerService: MailerService, + @Optional() private readonly mailerService: MailerService | undefined, private logger: LoggerService, protected readonly i18n: ExtendedI18nService, ) { @@ -54,24 +55,28 @@ export class InvitationService extends BaseService { */ async create(dto: InvitationCreateDto): Promise { const jwt = await this.sign(dto); - try { - await this.mailerService.sendMail({ - to: dto.email, - template: 'invitation.mjml', - context: { - token: jwt, - // TODO: Which language should we use? - t: (key: string) => this.i18n.t(key), - }, - }); - } catch (e) { - this.logger.error( - 'Could not send email', - e.message, - e.stack, - 'InvitationService', - ); - throw new InternalServerErrorException('Could not send email'); + if (this.mailerService) { + try { + await this.mailerService.sendMail({ + to: dto.email, + template: 'invitation.mjml', + context: { + token: jwt, + // TODO: Which language should we use? + t: (key: string) => + this.i18n.t(key, { lang: config.chatbot.lang.default }), + }, + subject: this.i18n.t('invitation_subject'), + }); + } catch (e) { + this.logger.error( + 'Could not send email', + e.message, + e.stack, + 'InvitationService', + ); + throw new InternalServerErrorException('Could not send email'); + } } const newInvitation = await super.create({ ...dto, token: jwt }); return { ...newInvitation, token: jwt }; diff --git a/api/src/user/services/passwordReset.service.ts b/api/src/user/services/passwordReset.service.ts index 0143c1c1..60143f82 100644 --- a/api/src/user/services/passwordReset.service.ts +++ b/api/src/user/services/passwordReset.service.ts @@ -13,6 +13,7 @@ import { Injectable, InternalServerErrorException, NotFoundException, + Optional, UnauthorizedException, } from '@nestjs/common'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; @@ -30,7 +31,7 @@ import { UserRequestResetDto, UserResetPasswordDto } from '../dto/user.dto'; export class PasswordResetService { constructor( @Inject(JwtService) private readonly jwtService: JwtService, - private readonly mailerService: MailerService, + @Optional() private readonly mailerService: MailerService | undefined, private logger: LoggerService, private readonly userService: UserService, public readonly i18n: ExtendedI18nService, @@ -55,24 +56,29 @@ export class PasswordResetService { throw new NotFoundException('User not found'); } const jwt = await this.sign(dto); - try { - await this.mailerService.sendMail({ - to: dto.email, - template: 'password_reset.mjml', - context: { - token: jwt, - first_name: user.first_name, - t: (key: string) => this.i18n.t(key), - }, - }); - } catch (e) { - this.logger.error( - 'Could not send email', - e.message, - e.stack, - 'InvitationService', - ); - throw new InternalServerErrorException('Could not send email'); + + if (this.mailerService) { + try { + await this.mailerService.sendMail({ + to: dto.email, + template: 'password_reset.mjml', + context: { + token: jwt, + first_name: user.first_name, + t: (key: string) => + this.i18n.t(key, { lang: config.chatbot.lang.default }), + }, + subject: this.i18n.t('password_reset_subject'), + }); + } catch (e) { + this.logger.error( + 'Could not send email', + e.message, + e.stack, + 'InvitationService', + ); + throw new InternalServerErrorException('Could not send email'); + } } // TODO: hash the token before saving it diff --git a/api/src/user/services/validate-account.service.ts b/api/src/user/services/validate-account.service.ts index 3dcdf80a..4c2d06c6 100644 --- a/api/src/user/services/validate-account.service.ts +++ b/api/src/user/services/validate-account.service.ts @@ -11,6 +11,7 @@ import { Inject, Injectable, InternalServerErrorException, + Optional, UnauthorizedException, } from '@nestjs/common'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; @@ -33,7 +34,7 @@ export class ValidateAccountService { constructor( @Inject(JwtService) private readonly jwtService: JwtService, private readonly userService: UserService, - private readonly mailerService: MailerService, + @Optional() private readonly mailerService: MailerService | undefined, private readonly i18n: ExtendedI18nService, ) {} @@ -71,16 +72,19 @@ export class ValidateAccountService { ) { const confirmationToken = await this.sign({ email: dto.email }); - await this.mailerService.sendMail({ - to: dto.email, - template: 'account_confirmation.mjml', - context: { - token: confirmationToken, - first_name: dto.first_name, - t: (key: string) => this.i18n.t(key), - }, - subject: 'Account confirmation Email', - }); + if (this.mailerService) { + await this.mailerService.sendMail({ + to: dto.email, + template: 'account_confirmation.mjml', + context: { + token: confirmationToken, + first_name: dto.first_name, + t: (key: string) => + this.i18n.t(key, { lang: config.chatbot.lang.default }), + }, + subject: this.i18n.t('account_confirmation_subject'), + }); + } } /** diff --git a/docker/.env.example b/docker/.env.example index 2104dcc3..8e4f837e 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -30,13 +30,15 @@ MONGO_PASSWORD=dev_only MONGO_URI=mongodb://${MONGO_USER}:${MONGO_PASSWORD}@mongo:27017/ MONGO_DB=hexabot -# SMTP Config for local dev env +# SMTP Config (for local dev env, use smtp4dev by doing `make start SMTP4DEV=1`) APP_SMTP_4_DEV_PORT=9002 +EMAIL_SMTP_ENABLED=false EMAIL_SMTP_HOST=smtp4dev EMAIL_SMTP_PORT=25 EMAIL_SMTP_SECURE=false EMAIL_SMTP_USER=dev_only EMAIL_SMTP_PASS=dev_only +EMAIL_SMTP_FROM=noreply@example.com # NLU Server AUTH_TOKEN=token123 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 724b6568..98c7912e 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -2,12 +2,18 @@ version: "3.8" services: database-init: + build: + context: ../api + pull_policy: build volumes: - ../api/src:/app/src - ../api/migrations:/app/migrations # - ../api/node_modules:/app/node_modules api: + build: + context: ../api + pull_policy: build ports: - ${API_PORT}:3000 - 9229:9229 # vscode debug port @@ -17,21 +23,6 @@ services: #- ../api/node_modules:/app/node_modules command: ["npm", "run", "start:debug"] - smtp4dev: - image: rnwood/smtp4dev:v3 - restart: always - ports: - - ${APP_SMTP_4_DEV_PORT}:80 - - "25:25" - - "143:143" - volumes: - - smtp4dev-data:/smtp4dev - environment: - - ServerOptions__HostName=smtp4dev - - ServerOptions__LockSettings=true - networks: - - db-network - mongo-express: container_name: mongoUi image: mongo-express:1-20 @@ -45,13 +36,19 @@ services: ME_CONFIG_MONGODB_ADMINPASSWORD: ${MONGO_PASSWORD} ME_CONFIG_MONGODB_URL: ${MONGO_URI} + hexabot-frontend: + container_name: frontend + build: + context: ../ + dockerfile: ./frontend/Dockerfile + pull_policy: build + widget: build: + context: ../widget target: development + pull_policy: build volumes: - ../widget/src:/app/src ports: - ${APP_WIDGET_PORT}:5173 - -volumes: - smtp4dev-data: diff --git a/docker/docker-compose.nlu.dev.yml b/docker/docker-compose.nlu.dev.yml index 8ea3a79c..b828f4a6 100644 --- a/docker/docker-compose.nlu.dev.yml +++ b/docker/docker-compose.nlu.dev.yml @@ -2,5 +2,8 @@ version: "3.9" services: nlu-api: + build: + context: ../nlu + dockerfile: Dockerfile ports: - - ${NLP_PORT}:5000 \ No newline at end of file + - ${NLP_PORT}:5000 diff --git a/docker/docker-compose.nlu.yml b/docker/docker-compose.nlu.yml index 7bc96732..9f5383a9 100644 --- a/docker/docker-compose.nlu.yml +++ b/docker/docker-compose.nlu.yml @@ -10,9 +10,7 @@ services: nlu-api: container_name: nlu-api - build: - context: ../nlu - dockerfile: Dockerfile + image: hexastack/hexabot-nlu:latest env_file: .env networks: - nlp-network diff --git a/docker/docker-compose.smtp4dev.yml b/docker/docker-compose.smtp4dev.yml new file mode 100644 index 00000000..4db737a4 --- /dev/null +++ b/docker/docker-compose.smtp4dev.yml @@ -0,0 +1,20 @@ +version: "3.8" + +services: + smtp4dev: + image: rnwood/smtp4dev:v3 + restart: always + ports: + - ${APP_SMTP_4_DEV_PORT}:80 + - "25:25" + - "143:143" + volumes: + - smtp4dev-data:/smtp4dev + environment: + - ServerOptions__HostName=smtp4dev + - ServerOptions__LockSettings=true + networks: + - app-network + +volumes: + smtp4dev-data: diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 80102c66..80bcdf3d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -3,8 +3,7 @@ version: "3.9" services: database-init: container_name: database-init - build: - context: ../api + image: hexastack/hexabot-api:latest command: sh -c "npm run cache:init && npm run migrate prune && npm run migrate up" env_file: .env networks: @@ -15,8 +14,7 @@ services: api: container_name: api - build: - context: ../api + image: hexastack/hexabot-api:latest env_file: .env ports: - ${API_PORT}:3000 @@ -30,7 +28,6 @@ services: condition: service_healthy database-init: condition: service_completed_successfully - healthcheck: test: "wget --spider http://localhost:3000" interval: 10s @@ -40,12 +37,7 @@ services: hexabot-frontend: container_name: frontend - build: - context: ../ - dockerfile: ./frontend/Dockerfile - args: - - NEXT_PUBLIC_API_ORIGIN=${NEXT_PUBLIC_API_ORIGIN} - - NEXT_PUBLIC_SSO_ENABLED=${NEXT_PUBLIC_SSO_ENABLED} + image: hexastack/hexabot-ui:latest env_file: .env ports: - ${APP_FRONTEND_PORT}:8080 @@ -75,12 +67,7 @@ services: widget: container_name: widget - build: - context: ../widget - args: - REACT_APP_WIDGET_API_URL: ${REACT_APP_WIDGET_API_URL} - REACT_APP_WIDGET_CHANNEL: ${REACT_APP_WIDGET_CHANNEL} - REACT_APP_WIDGET_TOKEN: ${REACT_APP_WIDGET_TOKEN} + image: hexastack/hexabot-widget:latest networks: - app-network depends_on: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index cea1c11b..d8d812c6 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -21,14 +21,6 @@ RUN \ # Rebuild the source code only when needed FROM base AS builder -ARG NEXT_PUBLIC_API_ORIGIN -ENV NEXT_PUBLIC_API_ORIGIN=${NEXT_PUBLIC_API_ORIGIN} -ARG NEXT_PUBLIC_SSO_ENABLED -ENV NEXT_PUBLIC_SSO_ENABLED=${NEXT_PUBLIC_SSO_ENABLED} - -ENV REACT_APP_WIDGET_API_URL=${NEXT_PUBLIC_API_ORIGIN} -ENV REACT_APP_WIDGET_CHANNEL=test -ENV REACT_APP_WIDGET_TOKEN=test WORKDIR /app @@ -57,10 +49,6 @@ ENV NODE_ENV production # Uncomment the following line in case you want to disable telemetry during runtime. ENV NEXT_TELEMETRY_DISABLED 1 -# Set the environment variable API_ORIGIN -ENV NEXT_PUBLIC_API_ORIGIN ${NEXT_PUBLIC_API_ORIGIN:-"http://localhost:3000"} -ENV NEXT_PUBLIC_SSO_ENABLED ${NEXT_PUBLIC_SSO_ENABLED:-"false"} - RUN addgroup --system --gid 1001 nodejs RUN adduser --system --uid 1001 nextjs diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 774cf83f..6d0080df 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,36 +1,24 @@ /** @type {import('next').NextConfig} */ import withTM from "next-transpile-modules"; -const apiUrl = process.env.NEXT_PUBLIC_API_ORIGIN || "http://localhost:4000/"; -const url = new URL(apiUrl); const nextConfig = withTM(["hexabot-widget"])({ + async rewrites() { + return [ + { + source: "/config", + destination: "/api/config", + }, + ]; + }, webpack(config, _options) { return config; }, publicRuntimeConfig: { - apiUrl, - ssoEnabled: process.env.NEXT_PUBLIC_SSO_ENABLED === "true", lang: { default: "en", }, }, output: "standalone", - images: { - remotePatterns: [ - { - protocol: "https", - hostname: url.hostname, - port: url.port, - pathname: "/attachment/**", - }, - { - protocol: "http", - hostname: url.hostname, - port: url.port, - pathname: "/attachment/**", - }, - ], - }, }); export default nextConfig; diff --git a/frontend/src/app-components/tables/columns/renderPicture.tsx b/frontend/src/app-components/tables/columns/renderPicture.tsx index 33af1740..3684a00a 100644 --- a/frontend/src/app-components/tables/columns/renderPicture.tsx +++ b/frontend/src/app-components/tables/columns/renderPicture.tsx @@ -11,12 +11,15 @@ import { Grid } from "@mui/material"; import { GridRenderCellParams } from "@mui/x-data-grid"; import { getAvatarSrc } from "@/components/inbox/helpers/mapMessages"; +import { useConfig } from "@/hooks/useConfig"; import { EntityType } from "@/services/types"; export const buildRenderPicture = ( entityType: EntityType.USER | EntityType.SUBSCRIBER, ) => function RenderPicture(params: GridRenderCellParams) { + const { apiUrl } = useConfig(); + return ( @@ -118,6 +123,7 @@ export function Chat() { i18n.language, )}`} src={getAvatarSrc( + apiUrl, message.sender ? EntityType.SUBSCRIBER : EntityType.USER, diff --git a/frontend/src/components/inbox/components/ChatActions.tsx b/frontend/src/components/inbox/components/ChatActions.tsx index b91f5cc9..605ab2e4 100644 --- a/frontend/src/components/inbox/components/ChatActions.tsx +++ b/frontend/src/components/inbox/components/ChatActions.tsx @@ -18,12 +18,14 @@ import { Input } from "@/app-components/inputs/Input"; import { useFind } from "@/hooks/crud/useFind"; import { useUpdate } from "@/hooks/crud/useUpdate"; import { useAuth } from "@/hooks/useAuth"; +import { useConfig } from "@/hooks/useConfig"; import { EntityType } from "@/services/types"; import { getAvatarSrc } from "../helpers/mapMessages"; import { useChat } from "../hooks/ChatContext"; export const ChatActions = () => { + const { apiUrl } = useConfig(); const { t } = useTranslation(); const { subscriber: activeChat } = useChat(); const [takeoverBy, setTakeoverBy] = useState( @@ -57,7 +59,7 @@ export const ChatActions = () => { diff --git a/frontend/src/components/inbox/components/ConversationsList.tsx b/frontend/src/components/inbox/components/ConversationsList.tsx index b032f514..666cadd4 100644 --- a/frontend/src/components/inbox/components/ConversationsList.tsx +++ b/frontend/src/components/inbox/components/ConversationsList.tsx @@ -16,6 +16,7 @@ import InboxIcon from "@mui/icons-material/MoveToInbox"; import { Chip, debounce, Grid } from "@mui/material"; import { useTranslation } from "react-i18next"; +import { useConfig } from "@/hooks/useConfig"; import { Title } from "@/layout/content/Title"; import { EntityType } from "@/services/types"; @@ -29,6 +30,7 @@ export const SubscribersList = (props: { searchPayload: any; assignedTo: AssignedTo; }) => { + const { apiUrl } = useConfig(); const { t, i18n } = useTranslation(); const chat = useChat(); const { fetchNextPage, isFetching, subscribers, hasNextPage } = @@ -58,6 +60,7 @@ export const SubscribersList = (props: { > } diff --git a/frontend/src/components/users/index.tsx b/frontend/src/components/users/index.tsx index a77391bf..6cde7a77 100644 --- a/frontend/src/components/users/index.tsx +++ b/frontend/src/components/users/index.tsx @@ -11,7 +11,6 @@ import { faUsers } from "@fortawesome/free-solid-svg-icons"; import PersonAddAlt1Icon from "@mui/icons-material/PersonAddAlt1"; import { Button, Grid, Paper } from "@mui/material"; import { GridColDef } from "@mui/x-data-grid"; -import getConfig from "next/config"; import { useTranslation } from "react-i18next"; import { ChipEntity } from "@/app-components/displays/ChipEntity"; @@ -25,6 +24,7 @@ import { buildRenderPicture } from "@/app-components/tables/columns/renderPictur import { DataGrid } from "@/app-components/tables/DataGrid"; import { useFind } from "@/hooks/crud/useFind"; import { useUpdate } from "@/hooks/crud/useUpdate"; +import { useConfig } from "@/hooks/useConfig"; import { getDisplayDialogs, useDialog } from "@/hooks/useDialog"; import { useHasPermission } from "@/hooks/useHasPermission"; import { useSearch } from "@/hooks/useSearch"; @@ -39,9 +39,8 @@ import { getDateTimeFormatter } from "@/utils/date"; import { EditUserDialog } from "./EditUserDialog"; import { InvitationDialog } from "./InvitationDialog"; -const { publicRuntimeConfig } = getConfig(); - export const Users = () => { + const { ssoEnabled } = useConfig(); const { t } = useTranslation(); const { toast } = useToast(); const { mutateAsync: updateUser } = useUpdate(EntityType.USER, { @@ -157,7 +156,7 @@ export const Users = () => { }, }); }} - disabled={publicRuntimeConfig.ssoEnabled} + disabled={ssoEnabled} > {t(params.row.state ? "label.enabled" : "label.disabled")} @@ -188,7 +187,7 @@ export const Users = () => { valueGetter: (params) => t("datetime.updated_at", getDateTimeFormatter(params)), }, - ...(!publicRuntimeConfig.ssoEnabled ? [actionColumns] : []), + ...(!ssoEnabled ? [actionColumns] : []), ]; return ( @@ -207,7 +206,7 @@ export const Users = () => { - {!publicRuntimeConfig.ssoEnabled && + {!ssoEnabled && hasPermission(EntityType.USER, PermissionAction.CREATE) ? (