diff --git a/.env.example b/.env.example index b7ba0920d..2ab2933dc 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ # https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables # Used to verify the integrity of signed cookies. so ensure a secure value is set -# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols. +# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols. # Use `rake secret` to generate this variable SECRET_KEY_BASE=replace_with_lengthy_secure_hex @@ -216,6 +216,8 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: # ENABLE_RACK_ATTACK=true # RACK_ATTACK_LIMIT=300 # ENABLE_RACK_ATTACK_WIDGET_API=true +# Comma-separated list of trusted IPs that bypass Rack Attack throttling rules +# RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10 ## Running chatwoot as an API only server ## setting this value to true will disable the frontend dashboard endpoints @@ -257,4 +259,3 @@ AZURE_APP_SECRET= # Set to true if you want to remove stale contact inboxes # contact_inboxes with no conversation older than 90 days will be removed # REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false - diff --git a/.gitignore b/.gitignore index 53deb62a8..c64fb5c1b 100644 --- a/.gitignore +++ b/.gitignore @@ -71,9 +71,6 @@ test/cypress/videos/* /config/master.key /config/*.enc -#ignore files under .vscode directory -.vscode -.cursor # yalc for local testing .yalc @@ -92,5 +89,8 @@ yarn-debug.log* # https://vitejs.dev/guide/env-and-mode.html#env-files *.local -# Claude.ai config file -CLAUDE.md + +# TextEditors & AI Agents config files +.vscode +.claude/settings.local.json +.cursor diff --git a/.husky/pre-commit b/.husky/pre-commit index adda426ad..b3aceacd6 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -4,8 +4,8 @@ # lint js and vue files npx --no-install lint-staged -# lint only staged ruby files -git diff --name-only --cached | xargs ls -1 2>/dev/null | grep '\.rb$' | xargs bundle exec rubocop --force-exclusion -a +# lint only staged ruby files that still exist (not deleted) +git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && echo "{}"' | grep '\.rb$' | xargs -I {} bundle exec rubocop --force-exclusion -a "{}" || true # stage rubocop changes to files -git diff --name-only --cached | xargs git add +git diff --name-only --cached | xargs -I {} sh -c 'test -f "{}" && git add "{}"' || true diff --git a/.windsurf/rules/chatwoot.md b/.windsurf/rules/chatwoot.md new file mode 120000 index 000000000..b7e6491d3 --- /dev/null +++ b/.windsurf/rules/chatwoot.md @@ -0,0 +1 @@ +../../AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..ad374799c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# Chatwoot Development Guidelines + +## Build / Test / Lint + +- **Setup**: `bundle install && pnpm install` +- **Run Dev**: `pnpm dev` or `overmind start -f ./Procfile.dev` +- **Lint JS/Vue**: `pnpm eslint` / `pnpm eslint:fix` +- **Lint Ruby**: `bundle exec rubocop -a` +- **Test JS**: `pnpm test` or `pnpm test:watch` +- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` +- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` +- **Run Project**: `overmind start -f Procfile.dev` + +## Code Style + +- **Ruby**: Follow RuboCop rules (150 character max line length) +- **Vue/JS**: Use ESLint (Airbnb base + Vue 3 recommended) +- **Vue Components**: Use PascalCase +- **Events**: Use camelCase +- **I18n**: No bare strings in templates; use i18n +- **Error Handling**: Use custom exceptions (`lib/custom_exceptions/`) +- **Models**: Validate presence/uniqueness, add proper indexes +- **Type Safety**: Use PropTypes in Vue, strong params in Rails +- **Naming**: Use clear, descriptive names with consistent casing +- **Vue API**: Always use Composition API with ` + + diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue index d5586cb12..789aba1c8 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/ContactNotes.vue @@ -87,8 +87,10 @@ useKeyboardEvents(keyboardEvents); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue index 17367c930..c11fa2d9d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ContactNoteItem.vue @@ -1,6 +1,8 @@ diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue index b9953dabb..18fabd2af 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryForm.vue @@ -200,6 +200,7 @@ defineExpose({ state, isSubmitDisabled }); :label="state.icon" color="slate" size="sm" + type="button" :icon="!state.icon ? 'i-lucide-smile-plus' : ''" class="!h-[2.4rem] !w-[2.375rem] absolute top-[1.94rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none" @click="isEmojiPickerOpen = !isEmojiPickerOpen" diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue index 5a60fefdc..34f1e003f 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/PortalSettingsPage/PortalBaseSettings.vue @@ -7,8 +7,8 @@ import { useStore, useStoreGetters } from 'dashboard/composables/store'; import { uploadFile } from 'dashboard/helper/uploadHelper'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { useVuelidate } from '@vuelidate/core'; -import { required, minLength } from '@vuelidate/validators'; -import { shouldBeUrl } from 'shared/helpers/Validators'; +import { required, minLength, helpers } from '@vuelidate/validators'; +import { shouldBeUrl, isValidSlug } from 'shared/helpers/Validators'; import Button from 'dashboard/components-next/button/Button.vue'; import Input from 'dashboard/components-next/input/Input.vue'; @@ -61,7 +61,16 @@ const liveChatWidgets = computed(() => { const rules = { name: { required, minLength: minLength(2) }, - slug: { required }, + slug: { + required: helpers.withMessage( + () => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'), + required + ), + isValidSlug: helpers.withMessage( + () => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'), + isValidSlug + ), + }, homePageLink: { shouldBeUrl }, }; @@ -71,9 +80,9 @@ const nameError = computed(() => v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : '' ); -const slugError = computed(() => - v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : '' -); +const slugError = computed(() => { + return v$.value.slug.$errors[0]?.$message || ''; +}); const homePageLinkError = computed(() => v$.value.homePageLink.$error diff --git a/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue index d245656d0..70c30a241 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/PortalSwitcher/CreatePortalDialog.vue @@ -6,8 +6,9 @@ import { useAlert, useTrack } from 'dashboard/composables'; import { PORTALS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events'; import { convertToCategorySlug } from 'dashboard/helper/commons.js'; import { useVuelidate } from '@vuelidate/core'; -import { required, minLength } from '@vuelidate/validators'; +import { required, minLength, helpers } from '@vuelidate/validators'; import { buildPortalURL } from 'dashboard/helper/portalHelper'; +import { isValidSlug } from 'shared/helpers/Validators'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; import Input from 'dashboard/components-next/input/Input.vue'; @@ -31,7 +32,16 @@ const state = reactive({ const rules = { name: { required, minLength: minLength(2) }, - slug: { required }, + slug: { + required: helpers.withMessage( + () => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR'), + required + ), + isValidSlug: helpers.withMessage( + () => t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.FORMAT_ERROR'), + isValidSlug + ), + }, }; const v$ = useVuelidate(rules, state); @@ -40,9 +50,9 @@ const nameError = computed(() => v$.value.name.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.ERROR') : '' ); -const slugError = computed(() => - v$.value.slug.$error ? t('HELP_CENTER.CREATE_PORTAL_DIALOG.SLUG.ERROR') : '' -); +const slugError = computed(() => { + return v$.value.slug.$errors[0]?.$message || ''; +}); const isSubmitDisabled = computed(() => v$.value.$invalid); @@ -131,6 +141,7 @@ defineExpose({ dialogRef }); :message=" nameError || t('HELP_CENTER.CREATE_PORTAL_DIALOG.NAME.MESSAGE') " + @blur="v$.name.$touch()" /> diff --git a/app/javascript/dashboard/components-next/TeleportWithDirection.vue b/app/javascript/dashboard/components-next/TeleportWithDirection.vue new file mode 100644 index 000000000..3e9009f08 --- /dev/null +++ b/app/javascript/dashboard/components-next/TeleportWithDirection.vue @@ -0,0 +1,28 @@ + + + + diff --git a/app/javascript/dashboard/components-next/captain/PageLayout.vue b/app/javascript/dashboard/components-next/captain/PageLayout.vue index e9fae9ca7..7355ac616 100644 --- a/app/javascript/dashboard/components-next/captain/PageLayout.vue +++ b/app/javascript/dashboard/components-next/captain/PageLayout.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import { usePolicy } from 'dashboard/composables/usePolicy'; import Button from 'dashboard/components-next/button/Button.vue'; +import BackButton from 'dashboard/components/widgets/BackButton.vue'; import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import Policy from 'dashboard/components/policy.vue'; @@ -23,6 +24,10 @@ const props = defineProps({ type: String, default: '', }, + backUrl: { + type: [String, Object], + default: '', + }, buttonPolicy: { type: Array, default: () => [], @@ -39,6 +44,10 @@ const props = defineProps({ type: Boolean, default: false, }, + showKnowMore: { + type: Boolean, + default: true, + }, isEmpty: { type: Boolean, default: false, @@ -73,19 +82,23 @@ const handlePageChange = event => { class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row" >
+ {{ headerTitle }} -
+
@@ -104,7 +117,7 @@ const handlePageChange = event => {
-
+
{