diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ca4dce..219c161 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Harassment, discrimination, or abusive behavior will not be tolerated. ### Reporting Bugs -1. Check existing [issues](https://github.com/EvolutionAPI/evo-auth-service-community/issues) +1. Check existing [issues](https://github.com/evolution-foundation/evo-auth-service-community/issues) to avoid duplicates 2. Open a new issue with: - Clear, descriptive title diff --git a/README.md b/README.md index ec723d6..931df83 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@

- Latest version + Latest version License: Apache 2.0 Documentation Community @@ -32,10 +32,10 @@ ## Part of the Evo CRM Community -Evo CRM Auth Service is part of the [Evo CRM Community](https://github.com/EvolutionAPI/evo-crm-community) ecosystem maintained by Evolution Foundation. To use the full stack, clone the umbrella repository with submodules: +Evo CRM Auth Service is part of the [Evo CRM Community](https://github.com/evolution-foundation/evo-crm-community) ecosystem maintained by Evolution Foundation. To use the full stack, clone the umbrella repository with submodules: ```bash -git clone --recurse-submodules git@github.com:EvolutionAPI/evo-crm-community.git +git clone --recurse-submodules git@github.com:evolution-foundation/evo-crm-community.git ``` The Community Edition is **single-tenant** by design — one account, no multi-tenancy overhead, no super-admin, no billing or plans. The role hierarchy is simple: `account_owner` and `agent`. @@ -80,7 +80,7 @@ The Community Edition is **single-tenant** by design — one account, no multi-t ### Installation ```bash -git clone git@github.com:EvolutionAPI/evo-auth-service-community.git +git clone git@github.com:evolution-foundation/evo-auth-service-community.git cd evo-auth-service-community # Install dependencies diff --git a/app/controllers/api/v1/profiles_controller.rb b/app/controllers/api/v1/profiles_controller.rb index dc25e64..044b621 100644 --- a/app/controllers/api/v1/profiles_controller.rb +++ b/app/controllers/api/v1/profiles_controller.rb @@ -25,6 +25,8 @@ def update filtered_params = profile_params if current_user.update(filtered_params) + TokenValidationService.invalidate_cache_for_user(current_user) if avatar_param.present? + message = if current_user.unconfirmed_email.present? 'Profile updated. Confirmation email sent to the new address.' else diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index 096055c..685ea91 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -36,6 +36,13 @@ def create message: 'User created successfully', status: :created ) + rescue ActiveRecord::RecordInvalid => e + error_response( + 'VALIDATION_ERROR', + e.record.errors.full_messages.join(', '), + status: :unprocessable_entity, + details: e.record.errors.messages + ) end def update diff --git a/app/controllers/setup_controller.rb b/app/controllers/setup_controller.rb index ab87a91..292e832 100644 --- a/app/controllers/setup_controller.rb +++ b/app/controllers/setup_controller.rb @@ -147,7 +147,7 @@ def bootstrap client_ip: request.remote_ip ) - render json: { status: 'ok', message: 'Installation completed successfully' }, status: :created + render json: { status: 'ok', message: 'Installation completed successfully', survey_token: result[:survey_token] }, status: :created rescue SetupBootstrapService::AlreadyBootstrappedError => e render json: { error: e.message }, status: :conflict @@ -156,12 +156,52 @@ def bootstrap render json: { error: e.message }, status: :unprocessable_entity end + # POST /setup/survey + # Saves onboarding survey answers for the initial admin user. + # Authenticated via a one-time survey_token generated during bootstrap (TTL: 10 min). + # If the token has expired, the frontend falls back to the authenticated endpoint + # (POST /api/v1/setup_survey) after the user logs in. + def survey + token = request.headers['X-Survey-Token'].to_s.strip + user_id = redis_client.get("survey_token:#{token}") + + if user_id.blank? + render json: { error: 'Invalid or expired survey token' }, status: :unauthorized + return + end + + user = User.find_by(id: user_id) + unless user + render json: { error: 'User not found' }, status: :not_found + return + end + + survey = user.setup_survey_response || user.build_setup_survey_response + survey.assign_attributes(survey_params) + + if survey.save + redis_client.del("survey_token:#{token}") + render json: { status: 'ok' }, status: :ok + else + render json: { error: survey.errors.full_messages.to_sentence }, status: :unprocessable_entity + end + end + private def bootstrap_params params.permit(:first_name, :last_name, :email, :password, :password_confirmation) end + def survey_params + params.permit(:team_size, :daily_volume, :main_channel, :main_channel_other, + :uses_ai, :biggest_pain, :crm_experience, :main_goal) + end + + def redis_client + Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/1')) + end + def resolve_instance_id(ctx) ctx.instance_id.presence || Licensing::Store.new.load_or_create_instance_id end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 8c4a705..6ee6780 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -10,14 +10,24 @@ module Avatarable after_save :fetch_avatar_from_gravatar end + # Signed URL TTL for cloud storage avatars. Short-lived to limit exposure if the + # URL leaks via logs, screenshots or cached API responses — frontend should + # re-fetch the user payload to refresh the URL when it expires. + AVATAR_URL_TTL = 1.hour + def avatar_url - return '' unless avatar.attached? + return nil unless avatar.attached? if avatar.service.class.name == 'ActiveStorage::Service::DiskService' - Rails.application.routes.url_helpers.rails_service_blob_proxy_path(avatar.signed_id, avatar.filename) + base_url = Rails.application.config.app_url.to_s.chomp('/') + path = Rails.application.routes.url_helpers.rails_service_blob_proxy_path(avatar.signed_id, avatar.filename) + "#{base_url}#{path}" else - avatar.url + avatar.blob.url(expires_in: AVATAR_URL_TTL) end + rescue StandardError => e + Rails.logger.error("[Avatarable] Avatar URL generation failed for #{self.class.name}##{id}: #{e.class} - #{e.message}") + nil end def fetch_avatar_from_gravatar diff --git a/app/models/setup_survey_response.rb b/app/models/setup_survey_response.rb new file mode 100644 index 0000000..1948c94 --- /dev/null +++ b/app/models/setup_survey_response.rb @@ -0,0 +1,3 @@ +class SetupSurveyResponse < ApplicationRecord + belongs_to :user +end diff --git a/app/models/user.rb b/app/models/user.rb index 7e07b1d..2ef8980 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -77,6 +77,12 @@ class User < ApplicationRecord has_many :user_roles, dependent: :destroy has_many :roles, through: :user_roles + has_many :user_tours, dependent: :destroy + has_one :setup_survey_response, dependent: :destroy + + def setup_survey_completed? + setup_survey_response.present? + end before_validation :set_password_and_uid, on: :create diff --git a/app/serializers/serializers.rb b/app/serializers/serializers.rb index 70d3ec5..25dab9b 100644 --- a/app/serializers/serializers.rb +++ b/app/serializers/serializers.rb @@ -50,7 +50,8 @@ def full(user, options = {}) message_signature: user.message_signature, provider: user.provider, uid: user.uid, - avatar_url: user.respond_to?(:avatar_url) ? user.avatar_url : nil + avatar_url: user.respond_to?(:avatar_url) ? user.avatar_url : nil, + setup_survey_completed: user.setup_survey_completed? ) # Add hmac_identifier if EVOLUTION_INBOX_HMAC_KEY is present diff --git a/app/serializers/user_serializer.rb b/app/serializers/user_serializer.rb index a129d2c..6899802 100644 --- a/app/serializers/user_serializer.rb +++ b/app/serializers/user_serializer.rb @@ -14,6 +14,7 @@ def full(user, options = {}) type: user.type, role: user.role_data, pubsub_token: user.pubsub_token, + avatar_url: user.avatar_url, created_at: user.created_at, updated_at: user.updated_at, ui_settings: user.ui_settings || {}, @@ -23,7 +24,8 @@ def full(user, options = {}) availability: user.availability, confirmed: user.confirmed?, confirmed_at: user.confirmed_at, - custom_attributes: user.custom_attributes || {} + custom_attributes: user.custom_attributes || {}, + setup_survey_completed: user.setup_survey_completed? } # Optional fields diff --git a/app/services/setup_bootstrap_service.rb b/app/services/setup_bootstrap_service.rb index 041acb5..80ebc8f 100644 --- a/app/services/setup_bootstrap_service.rb +++ b/app/services/setup_bootstrap_service.rb @@ -27,7 +27,8 @@ def call user = create_user assign_global_role(user) - { user: user } + survey_token = generate_survey_token(user) + { user: user, survey_token: survey_token } end activate_licensing(result[:user]) @@ -96,6 +97,19 @@ def create_oauth_app ) end + def generate_survey_token(user) + token = SecureRandom.hex(32) + redis_client.set("survey_token:#{token}", user.id, ex: 600) # 10 minutes TTL + token + rescue StandardError => e + Rails.logger.warn "[SetupBootstrap] Failed to generate survey token: #{e.message}" + nil + end + + def redis_client + Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/1')) + end + def activate_licensing(user) store = Licensing::Store.new instance_id = store.load_or_create_instance_id diff --git a/config/environments/development.rb b/config/environments/development.rb index 47d346b..f3023ac 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -37,7 +37,7 @@ config.action_mailer.perform_caching = false # Set localhost to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "localhost", port: 3000 } + config.action_mailer.default_url_options = { host: "localhost", port: 3001 } # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log @@ -73,8 +73,11 @@ config.hosts << "host.docker.internal" config.hosts << /.*\.docker\.internal/ - # Allow Docker Compose service hostnames + # Allow internal Docker service hostnames (used for inter-service calls) config.hosts << "evo-auth" + config.hosts << "evo-crm" + config.hosts << "evo-core" + config.hosts << "evo-processor" # Allow ngrok hosts for local development config.hosts << "evo-auth-davidson.ngrok.app" diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 820927b..0e4929a 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -8,10 +8,16 @@ # Parse and clean origins (remove whitespace and filter empty strings) cors_origins_raw = ENV.fetch('CORS_ORIGINS', 'http://localhost:3000,http://localhost:5173,http://localhost:8080,http://localhost:3001') cors_origins = cors_origins_raw.split(',').map(&:strip).reject(&:empty?) - + # Log CORS origins for debugging Rails.logger.info "CORS Origins configured: #{cors_origins.inspect}" + # ActiveStorage blobs - allow all origins (public resources, served as images) + allow do + origins '*' + resource '/rails/active_storage/*', headers: :any, methods: [:get, :options, :head] + end + allow do origins cors_origins diff --git a/config/initializers/environment_config.rb b/config/initializers/environment_config.rb index cc27c2a..21e295b 100644 --- a/config/initializers/environment_config.rb +++ b/config/initializers/environment_config.rb @@ -4,7 +4,29 @@ Rails.application.configure do # Basic app configuration Rails.application.config.app_name = 'Evo Auth Service' - Rails.application.config.app_url = 'http://localhost:3001' + auth_service_url = ENV.fetch('AUTH_SERVICE_URL', 'http://localhost:3001') + Rails.application.config.app_url = auth_service_url + + # Configure routes URL options so url_for in models generates absolute URLs. + # URI.parse accepts schemeless strings (e.g. "auth.example.com") without raising + # InvalidURIError — it returns scheme: nil, host: nil — so we validate explicitly. + begin + parsed = URI.parse(auth_service_url) + if parsed.scheme.nil? || !parsed.scheme.in?(%w[http https]) + Rails.logger.warn "AUTH_SERVICE_URL must include scheme http(s):// — got #{auth_service_url.inspect}; default_url_options not configured" + elsif parsed.host.blank? + Rails.logger.warn "AUTH_SERVICE_URL has no host — got #{auth_service_url.inspect}; default_url_options not configured" + else + Rails.application.routes.default_url_options = { + host: parsed.host, + port: parsed.port, + protocol: parsed.scheme + } + end + rescue URI::InvalidURIError => e + Rails.logger.warn "AUTH_SERVICE_URL is not a valid URI: #{e.message}" + end + Rails.application.config.enable_account_signup = true # MFA configuration with defaults diff --git a/config/routes.rb b/config/routes.rb index e0cb489..415afe6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -118,6 +118,12 @@ resource :account, only: [:show, :update], controller: 'account' + # User Tours - tracks which guided tours each user has completed + resources :user_tours, only: [:index, :create, :destroy] + + # Onboarding survey (authenticated — used when survey_token expired) + resource :setup_survey, only: [:show, :create], controller: 'setup_survey' + # User management resources :users, only: [:index, :create, :update, :destroy] do collection do @@ -169,10 +175,11 @@ # License management routes scope '/setup' do - get '/status', to: 'setup#status' - get '/register', to: 'setup#register' + get '/status', to: 'setup#status' + get '/register', to: 'setup#register' get '/activate', to: 'setup#activate' post '/bootstrap', to: 'setup#bootstrap' + post '/survey', to: 'setup#survey' end # Health check