diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95347033..c0872fcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,11 +52,17 @@ jobs: bundle exec rails db:create bundle exec rails db:migrate - - name: Run tests + - name: Run Non-JS tests env: RAILS_ENV: test DATABASE_URL: postgres://postgres:postgres@localhost:5432/access_pdf_test - run: bundle exec rspec + run: bundle exec rspec spec/models spec/requests + + - name: Run JS tests + env: + RAILS_ENV: test + DATABASE_URL: postgres://postgres:postgres@localhost:5432/access_pdf_test + run: bundle exec rspec spec/features - name: Keep screenshots from failed system tests uses: actions/upload-artifact@v4 diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index a1949bdf..8c364ab1 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -14,14 +14,14 @@ on: description: Inference Model options: - gemini-2.5-pro-preview-03-25 - - gemini-1.5-pro-latest + - gemini-2.0-flash + - gemini-2.0-flash-lite evaluation_model_name: type: choice description: Evaluation Model options: - gemini-2.5-flash - gemini-2.5-pro-preview-03-25 - - gemini-1.5-pro-latest runs_per_document: description: 'Number of times generate evaluations per document' required: false diff --git a/.github/workflows/python_components.yml b/.github/workflows/python_components.yml index b97ba750..da4becc1 100644 --- a/.github/workflows/python_components.yml +++ b/.github/workflows/python_components.yml @@ -26,6 +26,7 @@ jobs: python -m pip install --upgrade pip if [ -f ./python_components/ci/requirements.txt ]; then pip install -r ./python_components/ci/requirements.txt; fi pip install python_components/evaluation + pip install python_components/document_inference python -m spacy download en_core_web_sm - name: Run Linting run: | diff --git a/Gemfile b/Gemfile index 451fcc96..800e5419 100644 --- a/Gemfile +++ b/Gemfile @@ -54,6 +54,7 @@ group :test do gem "rails-controller-testing" gem "factory_bot_rails", "~> 6.5" gem "capybara" + gem "capybara-email" gem "webdrivers" end @@ -62,6 +63,7 @@ gem "aws-sdk-s3", "~> 1.194" # For S3 versioning support gem "aws-sdk-secretsmanager" gem "aws-sdk-lambda" gem "aws-sigv4" +gem "aws-sdk-ses" # API and Documentation gem "grape", "~> 2.4" diff --git a/Gemfile.lock b/Gemfile.lock index 69f4fc7b..ff731c2d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -103,6 +103,9 @@ GEM aws-sdk-secretsmanager (1.117.0) aws-sdk-core (~> 3, >= 3.227.0) aws-sigv4 (~> 1.5) + aws-sdk-ses (1.85.0) + aws-sdk-core (~> 3, >= 3.225.0) + aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) base64 (0.3.0) @@ -131,6 +134,9 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + capybara-email (3.0.2) + capybara (>= 2.4, < 4.0) + mail cgi (0.5.0) chartkick (5.2.0) childprocess (5.1.0) @@ -286,7 +292,11 @@ GEM parser (3.3.8.0) ast (~> 2.4.1) racc - pg (1.5.9) + pg (1.6.0) + pg (1.6.0-aarch64-linux) + pg (1.6.0-arm64-darwin) + pg (1.6.0-x86_64-darwin) + pg (1.6.0-x86_64-linux) pp (0.6.2) prettyprint prettyprint (0.2.0) @@ -518,6 +528,7 @@ DEPENDENCIES aws-sdk-lambda aws-sdk-s3 (~> 1.194) aws-sdk-secretsmanager + aws-sdk-ses aws-sigv4 bcrypt (~> 3.1) better_errors (~> 2.10) @@ -525,6 +536,7 @@ DEPENDENCIES brakeman (~> 7.1) bundler-audit (~> 0.9.2) capybara + capybara-email chartkick cssbundling-rails (~> 1.4.3) csv diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb new file mode 100644 index 00000000..929c7b6f --- /dev/null +++ b/app/controllers/admin/users_controller.rb @@ -0,0 +1,83 @@ +class Admin::UsersController < ApplicationController + include Access + + before_action :ensure_user_is_user_admin + + before_action :set_user, only: [:edit, :update] + before_action :site_list, only: [:new, :create, :edit, :update] + before_action :set_minimum_password_length, only: [:new, :edit, :update] + + def index + @users = User.all + end + + def new + @user = User.new + render :new + end + + def create + @user = User.new(user_params) + if @user.is_invited? + temp_password = SecureRandom.hex(12) + @user.password = temp_password + @user.password_confirmation = temp_password + end + if @user.save + begin + msg = "User added successfully" + if @user.send_new_account_instructions? + msg = "User added successfully. Instructions were emailed to the user." + end + redirect_to admin_users_path, notice: msg + rescue Net::SMTPFatalError => e + redirect_to admin_users_path, alert: e.message + end + else + render :new, status: 422 + end + end + + def edit + render :edit + end + + def update + if params[:user][:password].blank? + params[:user].delete(:password) + params[:user].delete(:password_confirmation) + params[:user].delete(:current_password) + success = @user.update_without_password(user_params) + elsif @user.id == current_user.id + success = @user.update_with_password(user_params) + bypass_sign_in @user, scope: "user" + else + success = @user.update(user_params) + end + if success + redirect_to admin_users_path, notice: "User updated successfully" + else + render :edit, status: 422 + end + end + + private + + def site_list + @sites = Site.all.order(:location, :name).group_by(&:location).map do |location, sites| + [location, sites.map { |site| [site.name, site.id] }] + end + end + + def set_user + @user = User.find(params[:id]) + end + + def user_params + params.require(:user).permit(:email, :password, :password_confirmation, :current_password, :is_site_admin, :is_user_admin, :site_id, :is_invited) + end + + def set_minimum_password_length + @minimum_password_length = User.password_length.min + end +end diff --git a/app/controllers/concerns/access.rb b/app/controllers/concerns/access.rb index 07554a99..b6e0861c 100644 --- a/app/controllers/concerns/access.rb +++ b/app/controllers/concerns/access.rb @@ -1,18 +1,24 @@ module Access - def ensure_user_admin - unless current_user.is_admin? + def ensure_user_site_admin + unless current_user.present? && current_user.is_site_admin? + redirect_to sites_path, alert: "You don't have permission to access that page." + end + end + + def ensure_user_is_user_admin + unless current_user.present? && current_user.is_user_admin? redirect_to sites_path, alert: "You don't have permission to access that page." end end def ensure_user_site_access - if !current_user.is_admin? && (current_user.site.nil? || current_user.site.id != @site.id) + if current_user.nil? || !current_user.is_site_admin? && (current_user.site.nil? || current_user.site.id != @site.id) redirect_to sites_path, alert: "You don't have permission to access that site." end end def ensure_user_document_access - if !current_user.is_admin? && (current_user.site.nil? || current_user.site.documents.find(@document.id).nil?) + if current_user.nil? || !current_user.is_site_admin? && (current_user.site.nil? || current_user.site.documents.find(@document.id).nil?) redirect_to sites_path, alert: "You don't have permission to perform that action on document." end end diff --git a/app/controllers/configurations_controller.rb b/app/controllers/configurations_controller.rb index a516be67..bf13c4c5 100644 --- a/app/controllers/configurations_controller.rb +++ b/app/controllers/configurations_controller.rb @@ -1,7 +1,7 @@ class ConfigurationsController < AuthenticatedController include Access - before_action :ensure_user_admin + before_action :ensure_user_site_admin ASAP_API_USER = "/asap-pdf/production/RAILS_API_USER-20250613220933079900000001" ASAP_API_PASSWORD = "/asap-pdf/production/RAILS_API_PASSWORD-20250613220933080000000003" diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index 8aa19d16..fde11e78 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -6,7 +6,7 @@ class SitesController < AuthenticatedController before_action :ensure_user_site_access, only: [:insights, :show, :edit, :update, :destroy] def index - @sites = if current_user.is_admin? + @sites = if current_user.is_site_admin? Site.all else current_user.site.nil? ? [] : [current_user.site] diff --git a/app/controllers/users/passwords_controller.rb b/app/controllers/users/passwords_controller.rb index 9f2d9aee..ce8cd164 100644 --- a/app/controllers/users/passwords_controller.rb +++ b/app/controllers/users/passwords_controller.rb @@ -2,34 +2,18 @@ class Users::PasswordsController < Devise::PasswordsController layout "centered" - # GET /resource/password/new - # def new - # super - # end - # POST /resource/password - # def create - # super - # end + def create + self.resource = resource_class.send_reset_password_instructions(resource_params) + set_flash_message! :notice, :send_paranoid_instructions + resource.email = nil + redirect_back(fallback_location: new_password_path(resource_name)) + end - # GET /resource/password/edit?reset_password_token=abcdef - # def edit - # super - # end - - # PUT /resource/password - # def update - # super - # end - - # protected - - # def after_resetting_password_path_for(resource) - # super(resource) - # end - - # The path used after sending reset password instructions - # def after_sending_reset_password_instructions_path_for(resource_name) - # super(resource_name) - # end + def edit + @is_invitation = params[:is_invitation] == "1" + self.resource = resource_class.new + set_minimum_password_length + resource.reset_password_token = params[:reset_password_token] + end end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index c266662e..0ddd28c2 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -1,63 +1,11 @@ # frozen_string_literal: true class Users::RegistrationsController < Devise::RegistrationsController - layout "centered" - # before_action :configure_sign_up_params, only: [:create] - # before_action :configure_account_update_params, only: [:update] + def new + redirect_to root_path + end - # GET /resource/sign_up - # def new - # super - # end - - # POST /resource - # def create - # super - # end - - # GET /resource/edit - # def edit - # super - # end - - # PUT /resource - # def update - # super - # end - - # DELETE /resource - # def destroy - # super - # end - - # GET /resource/cancel - # Forces the session data which is usually expired after sign - # in to be expired now. This is useful if the user wants to - # cancel oauth signing in/up in the middle of the process, - # removing all OAuth session data. - # def cancel - # super - # end - - # protected - - # If you have extra params to permit, append them to the sanitizer. - # def configure_sign_up_params - # devise_parameter_sanitizer.permit(:sign_up, keys: [:attribute]) - # end - - # If you have extra params to permit, append them to the sanitizer. - # def configure_account_update_params - # devise_parameter_sanitizer.permit(:account_update, keys: [:attribute]) - # end - - # The path used after sign up. - # def after_sign_up_path_for(resource) - # super(resource) - # end - - # The path used after sign up for inactive accounts. - # def after_inactive_sign_up_path_for(resource) - # super(resource) - # end + def create + redirect_to root_path + end end diff --git a/app/javascript/controllers/modal_controller.js b/app/javascript/controllers/modal_controller.js index 8304a8d3..37554b8f 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/javascript/controllers/modal_controller.js @@ -5,7 +5,9 @@ export default class extends Controller { connect() { super.connect(); - this.wrapperTarget.addEventListener('close', this.onModalClose.bind(this)) + if (this.hasWrapperTarget) { + this.wrapperTarget.addEventListener('close', this.onModalClose.bind(this)) + } } submitAndClose(event) { diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index 3c34c814..710b5421 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,4 +1,11 @@ -class ApplicationMailer < ActionMailer::Base - default from: "from@example.com" +class ApplicationMailer < Devise::Mailer + default from: "Code for America " + layout "mailer" + + def new_account_instructions(record, token) + @token = token + @resource = record + mail(to: @resource.email, subject: "Welcome! Set up your account", template_path: "users/mailer") + end end diff --git a/app/models/document.rb b/app/models/document.rb index a31d6801..767a5cb9 100644 --- a/app/models/document.rb +++ b/app/models/document.rb @@ -230,7 +230,7 @@ def inference_summary! api_host = "https://demo.codeforamerica.ai" end payload = { - model_name: "gemini-1.5-pro-latest", + model_name: "gemini-2.0-flash", documents: [{id: id, title: file_name, url: normalized_url, purpose: document_category}], page_limit: 7, inference_type: "summary", diff --git a/app/models/user.rb b/app/models/user.rb index 29f06430..71865233 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,14 +1,15 @@ class User < ApplicationRecord - # Include default devise modules. Others available are: - # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable + :recoverable, :rememberable, :validatable, :trackable - # has_many :sessions, dependent: :destroy belongs_to :site, optional: true delegate :documents, to: :site, allow_nil: true - # normalizes :email_address, with: ->(e) { e.strip.downcase } + def send_new_account_instructions? + return false unless is_invited? - # validates :email_address, presence: true, uniqueness: {case_sensitive: false}, format: {with: URI::MailTo::EMAIL_REGEXP} + token = set_reset_password_token + ApplicationMailer.new_account_instructions(self, token).deliver_now + true + end end diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb new file mode 100644 index 00000000..c984430f --- /dev/null +++ b/app/views/admin/users/edit.html.erb @@ -0,0 +1,82 @@ +
+
+

Edit <%= @user.email %>

+ + <%= form_with(model: [:admin, @user]) do |f| %> + +
+ <%= f.label :email, class: "label-text text-black mb-2 font-semibold" %> + <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "input input-bordered w-full" + (@user.errors[:email].any? ? " input-error " : " border-black ") %> + <% if @user.errors[:email].any? %> +
+ <%= @user.errors.full_message(:email, @user.errors[:email].first) %> +
+ <% end %> +
+ + <% if @user.id == current_user.id %> +
+ <%= f.label :current_password, class: "label-text text-black mb-2 font-semibold" %> + <%= f.password_field :current_password, autocomplete: "current-password", class: "input input-bordered w-full" + (@user.errors[:current_password].any? ? " input-error " : " border-black ") %> + We need your current password to confirm any password changes. + <% if @user.errors[:current_password].any? %> +
+ <%= @user.errors.full_message(:current_password, @user.errors[:current_password].first) %> +
+ <% end %> +
+ <% end %> + +
+ <%= f.label :password, "New password", class: "label-text text-black mb-2 font-semibold" %> + <%= f.password_field :password, autocomplete: "new-password", class: "input input-bordered w-full" + (@user.errors[:password].any? ? " input-error " : " border-black ") %> + <% if @minimum_password_length %> + + <% if @minimum_password_length %><%= @minimum_password_length %> characters minimum.<% end %> Leave blank if you don't want to change it. + <% end %> + <% if @user.errors[:password].any? %> +
+ <%= @user.errors.full_message(:password, @user.errors[:password].first) %> +
+ <% end %> +
+ +
+ <%= f.label :password_confirmation, "New password confirmation", class: "label-text text-black mb-2 font-semibold" %> + <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "input input-bordered w-full" + (@user.errors[:password_confirmation].any? ? " input-error " : " border-black ") %> + <% if @user.errors[:password_confirmation].any? %> +
+ <%= @user.errors.full_message(:password_confirmation, @user.errors[:password_confirmation].first) %> +
+ <% end %> +
+ +
+ <%= f.check_box :is_site_admin %> + <%= f.label :is_site_admin, class: "label-text text-black mb-2 font-semibold" %> +
User should be able to see and edit all sites and documents.
+
+ +
+ <%= f.check_box :is_user_admin %> + <%= f.label :is_user_admin, class: "label-text text-black mb-2 font-semibold" %> +
User should be able to edit other users and create admins.
+
+ +
+ <%= f.label :site_id, "Site", class: "label-text text-black mb-2 font-semibold" %> + <%= f.select :site_id, grouped_options_for_select(@sites, @user.site_id), + { include_blank: "None" }, + { class: "input input-bordered w-full select border-black" } %> +
+ +
+ <%= button_tag type: "submit", class: "btn btn-primary text-white", id: "submit-user-form" do %> + + Update + <% end %> + <%= link_to "Back", :back %> +
+ <% end %> +
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb new file mode 100644 index 00000000..7cadcf62 --- /dev/null +++ b/app/views/admin/users/index.html.erb @@ -0,0 +1,41 @@ +
+
+
+
+
+

Manage Users

+ + + Add User + +
+
+
+ + + + + + + + + + + + <% @users.each do |user| %> + + + + + + + + <% end %> + +
EmailSiteSite AdminUser AdminActions
<%= user.email %><%= user.site.present? ? user.site.name : "None" %><%= user.is_site_admin? ? "Yes" : "No" %><%= user.is_user_admin? ? "Yes" : "No" %> + <%= link_to "Edit", edit_admin_user_path(user), class: "text-primary" %> +
+
+
+
+
\ No newline at end of file diff --git a/app/views/admin/users/new.html.erb b/app/views/admin/users/new.html.erb new file mode 100644 index 00000000..cef07771 --- /dev/null +++ b/app/views/admin/users/new.html.erb @@ -0,0 +1,71 @@ +
+
+

Add New User

+ + <%= form_for(@user, url: admin_users_path) do |f| %> +
+ <%= f.label :email, class: "label-text text-black mb-2 font-semibold" %> + <%= f.email_field :email, autofocus: true, autocomplete: "email", class: "input input-bordered w-full" + (@user.errors[:email].any? ? " input-error " : " border-black ") %> + <% if @user.errors[:email].any? %> +
+ <%= @user.errors.full_message(:email, @user.errors[:email].first)%> +
+ <% end %> +
+
+ <%= f.check_box :is_invited %> + <%= f.label :is_invited, "Send invitation email", class: "label-text text-black mb-2 font-semibold" %> +
Send the user an invitation email, which includes a link to set password (skip password fields below).
+
+
+ <%= f.label :password, class: "label-text text-black mb-2 font-semibold" %> + <%= f.password_field :password, autocomplete: "new-password", class: "input input-bordered w-full" + (@user.errors[:password].any? ? " input-error " : " border-black ") %> + <% if @minimum_password_length %> + <%= @minimum_password_length %> characters minimum. + <% end %> + <% if @user.errors[:password].any? %> +
+ <%= @user.errors.full_message(:password, @user.errors[:password].first)%> +
+ <% end %> +
+ +
+ <%= f.label :password_confirmation, class: "label-text text-black mb-2 font-semibold" %> + <%= f.password_field :password_confirmation, autocomplete: "new-password", class: "input input-bordered w-full" + (@user.errors[:password_confirmation].any? ? " input-error " : " border-black ") %> + <% if @user.errors[:password_confirmation].any? %> +
+ <%= @user.errors.full_message(:password_confirmation, @user.errors[:password_confirmation].first)%> +
+ <% end %> +
+ +
+ <%= f.check_box :is_site_admin %> + <%= f.label :is_site_admin, class: "label-text text-black mb-2 font-semibold" %> +
User should be able to see and edit all sites and documents.
+
+ +
+ <%= f.check_box :is_user_admin %> + <%= f.label :is_user_admin, class: "label-text text-black mb-2 font-semibold" %> +
User should be able to edit other users and create admins.
+
+ +
+ <%= f.label :site_id, "Site", class: "label-text text-black mb-2 font-semibold" %> + <%= f.select :site_id, grouped_options_for_select(@sites, @user.site_id), + { include_blank: "None" }, + { class: "input input-bordered w-full select border-black" } %> +
+ +
+ <%= button_tag type: "submit", class: "btn btn-primary text-white", id: "submit-user-form" do %> + + Save + <% end %> + <%= link_to "Back", :back %> +
+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1d01d144..8347acec 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -36,14 +36,22 @@ -<% if current_user.is_admin? %> +<% if current_user.is_site_admin? %>