diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
index f4079d5..710ec63 100644
--- a/.github/workflows/workflow.yml
+++ b/.github/workflows/workflow.yml
@@ -70,5 +70,7 @@ jobs:
run: |
bundler exec rails db:create
bundler exec rails db:migrate
+ - name: Enhance prepare commands with tailwindcss:build
+ run: bundle exec rake spec:prepare
- name: Run tests
run: bundler exec rspec
diff --git a/.rubocop.yml b/.rubocop.yml
index f8d8e07..69aad8b 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -23,4 +23,5 @@ Metrics/MethodLength:
Max: 20
Style/Documentation:
- Enabled: false
\ No newline at end of file
+ Enabled: false
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index a73b039..8f58215 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,17 +16,6 @@ RUN apt-get update -qq && apt-get install -y \
build-essential \
curl
-# Install rbenv
-RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv && \
- echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc && \
- echo 'eval "$(rbenv init -)"' >> ~/.bashrc && \
- git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build && \
- echo 'export PATH="$HOME/.rbenv/plugins/ruby-build/bin:$PATH"' >> ~/.bashrc
-
-# Install the specified Ruby version using rbenv
-ENV PATH="/root/.rbenv/bin:/root/.rbenv/shims:$PATH"
-RUN rbenv install $RUBY_VERSION && rbenv global $RUBY_VERSION
-
# Set the working directory
WORKDIR /app
@@ -35,7 +24,7 @@ COPY Gemfile /app/Gemfile
COPY Gemfile.lock /app/Gemfile.lock
# Install Gems dependencies
-RUN gem install bundler && bundle install
+RUN gem install bundler && bundle install --jobs 4 --retry 3
# Copy the application code
COPY . /app
diff --git a/Gemfile b/Gemfile
index d7f49ca..690d8e2 100644
--- a/Gemfile
+++ b/Gemfile
@@ -58,14 +58,19 @@ gem 'email_validator'
# Styling
gem 'bootstrap'
-gem 'sassc-rails'
-gem 'tailwindcss-rails', '~> 2.7'
+gem 'sassc', '~> 2.4'
+gem 'sassc-rails', '~> 2.1.2'
+gem 'tailwindcss-rails', '~> 3.1'
gem 'dotenv-rails'
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
# gem "image_processing", "~> 1.2"
+group :test do
+ gem 'rails-controller-testing'
+end
+
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem 'debug', platforms: %i[mri windows]
@@ -87,6 +92,9 @@ group :development do
gem 'rubocop-rspec', require: false
gem 'rubocop-rspec_rails', require: false
+ # To see in what paths rails are looking for translations
+ gem 'i18n-debug'
+
# Add speed badges [https://github.com/MiniProfiler/rack-mini-profiler]
# gem "rack-mini-profiler"
diff --git a/Gemfile.lock b/Gemfile.lock
index 4f7dccb..81879fc 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -132,6 +132,8 @@ GEM
activesupport (>= 6.1)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
+ i18n-debug (1.2.0)
+ i18n (< 2)
importmap-rails (2.0.2)
actionpack (>= 6.0.0)
activesupport (>= 6.0.0)
@@ -217,6 +219,10 @@ GEM
activesupport (= 7.1.5.1)
bundler (>= 1.15.0)
railties (= 7.1.5.1)
+ rails-controller-testing (1.0.5)
+ actionpack (>= 5.0.1.rc1)
+ actionview (>= 5.0.1.rc1)
+ activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -307,18 +313,15 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.1)
- tailwindcss-rails (2.7.7)
- railties (>= 7.0.0)
- tailwindcss-rails (2.7.7-aarch64-linux)
- railties (>= 7.0.0)
- tailwindcss-rails (2.7.7-arm-linux)
- railties (>= 7.0.0)
- tailwindcss-rails (2.7.7-arm64-darwin)
- railties (>= 7.0.0)
- tailwindcss-rails (2.7.7-x86_64-darwin)
- railties (>= 7.0.0)
- tailwindcss-rails (2.7.7-x86_64-linux)
+ tailwindcss-rails (3.1.0)
railties (>= 7.0.0)
+ tailwindcss-ruby
+ tailwindcss-ruby (3.4.17)
+ tailwindcss-ruby (3.4.17-aarch64-linux)
+ tailwindcss-ruby (3.4.17-arm-linux)
+ tailwindcss-ruby (3.4.17-arm64-darwin)
+ tailwindcss-ruby (3.4.17-x86_64-darwin)
+ tailwindcss-ruby (3.4.17-x86_64-linux)
thor (1.3.2)
tilt (2.4.0)
timeout (0.4.1)
@@ -361,12 +364,14 @@ DEPENDENCIES
email_validator
factory_bot_rails
faker
+ i18n-debug
importmap-rails
jbuilder
pg (~> 1.1)
puma (>= 5.0)
pundit
rails (~> 7.1.3, >= 7.1.3.4)
+ rails-controller-testing
rails-html-sanitizer (>= 1.6.1)
rolify
rspec-rails
@@ -376,10 +381,11 @@ DEPENDENCIES
rubocop-rails
rubocop-rspec
rubocop-rspec_rails
- sassc-rails
+ sassc (~> 2.4)
+ sassc-rails (~> 2.1.2)
sprockets-rails
stimulus-rails
- tailwindcss-rails (~> 2.7)
+ tailwindcss-rails (~> 3.1)
turbo-rails
tzinfo-data
web-console
diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css
index 8666d2f..b5c61c9 100644
--- a/app/assets/stylesheets/application.tailwind.css
+++ b/app/assets/stylesheets/application.tailwind.css
@@ -1,13 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-
-/*
-
-@layer components {
- .btn-primary {
- @apply py-2 px-4 bg-blue-200;
- }
-}
-
-*/
diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb
new file mode 100644
index 0000000..5b9adaf
--- /dev/null
+++ b/app/controllers/admin/users_controller.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module Admin
+ class UsersController < ApplicationController
+ before_action :set_user, only: %i[show edit update destroy update edit_roles update_roles]
+ before_action :set_roles_list, only: %i[edit_roles update_roles]
+ before_action :set_user_params_roles, only: %i[update_roles]
+
+ # GET /admin/users or /admin/users.json
+ def index
+ @users = policy_scope(User.all)
+ end
+
+ # GET /admin/users/1 or /admin/users/1.json
+ def show
+ authorize @user
+ end
+
+ # GET /admin/users/1/edit
+ def edit
+ authorize @user
+ end
+
+ # PATCH/PUT /admin/users/1 or /admin/users/1.json
+ def update
+ authorize @user
+
+ respond_to do |format|
+ if @user.update(user_params)
+ format.html { redirect_to admin_user_url(@user), notice: t('.success') }
+ format.json { render :show, status: :ok, location: @user }
+ else
+ format.html { render :edit, status: :unprocessable_entity }
+ format.json { render json: @user.errors, status: :unprocessable_entity }
+ end
+ end
+ end
+
+ # DELETE /admin/users/1 or /admin/users/1.json
+ def destroy
+ authorize @user
+ @user.destroy!
+
+ respond_to do |format|
+ format.html { redirect_to admin_users_path, status: :see_other, notice: t('.success') }
+ format.json { head :no_content }
+ end
+ end
+
+ # GET /admin/users/1/edit_roles or /admin/users/1/edit_roles.json
+ def edit_roles
+ authorize @user
+ end
+
+ # POST /admin/users/1/update_roles or /admin/users/1/update_roles.json
+ def update_roles
+ authorize @user
+
+ respond_to do |format|
+ if @user.update_roles?(@user_params_roles)
+ format.html { redirect_to admin_user_url(@user), notice: t('.success') }
+ format.json { render :show, status: :ok, location: @user }
+ else
+ format.html { render :edit_roles, status: :unprocessable_entity }
+ format.json { render json: @user.errors, status: :unprocessable_entity }
+ end
+ end
+ end
+
+ private
+
+ # Use callbacks to share common setup or constraints between actions.
+ def set_user
+ @user = User.find(params[:id])
+ end
+
+ def set_roles_list
+ @roles = Role.distinct.pluck(:name).map { |role| [role.capitalize, role] }
+ end
+
+ def set_user_params_roles
+ @user_params_roles = user_params[:roles].compact_blank
+ end
+
+ # Only allow a list of trusted parameters through.
+ def user_params
+ params.require(:user).permit(:first_name, :last_name, :email, roles: [])
+ end
+ end
+end
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
new file mode 100644
index 0000000..32eb038
--- /dev/null
+++ b/app/helpers/users_helper.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module UsersHelper
+ def get_user_roles_list_as_string(user)
+ user.user_roles_names.map { |name| User::Roles::ROLES_MAP[name.to_sym] }.join(', ')
+ end
+
+ def get_user_role_ids_list(user)
+ user.user_roles_names
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 3858dd1..09bd22a 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,6 +1,8 @@
# frozen_string_literal: true
class User < ApplicationRecord
+ include ActionView::Helpers::TranslationHelper
+
rolify
resourcify
after_commit :assign_default_role, on: :create
@@ -15,40 +17,96 @@ class User < ApplicationRecord
has_many :measurements, dependent: :destroy
has_and_belongs_to_many :roles, join_table: :users_roles # rubocop:disable Rails/HasAndBelongsToMany
+ accepts_nested_attributes_for :roles
+
validates :email, presence: true, uniqueness: true, email: true
def full_name
"#{first_name} #{last_name}"
end
+ def user_roles_names
+ roles.pluck(:name)
+ end
+
def full_access_roles_can?
- has_any_role?(*User::Roles::FULL_ACCESS_ROLES)
+ has_any_role?(*Roles::FULL_ACCESS_ROLES)
end
def all_roles_can?
- has_any_role?(*User::Roles::ROLES)
+ has_any_role?(*Roles::ROLES)
end
def admin?
- has_role?(User::Roles::ADMIN)
+ has_role?(Roles::ADMIN)
end
def doctor?
- has_role?(User::Roles::DOCTOR)
+ has_role?(Roles::DOCTOR)
end
def health_coach?
- has_role?(User::Roles::HEALTH_COACH)
+ has_role?(Roles::HEALTH_COACH)
end
def user?
- has_role?(User::Roles::USER)
+ has_role?(Roles::USER)
+ end
+
+ def update_roles?(new_roles)
+ User.transaction do
+ synchronize_roles(new_roles)
+
+ raise ActiveRecord::Rollback unless errors.empty?
+ rescue StandardError => e
+ handle_unexpected_transaction_error(e)
+
+ raise ActiveRecord::Rollback
+ end
+ are_errors_empty = errors.empty?
+
+ unless are_errors_empty
+ Rails.logger.error("Role update failed for Account ID #{id}: #{errors.full_messages.join(', ')}")
+ end
+
+ are_errors_empty
end
private
def assign_default_role
- add_role(User::Roles::USER) if roles.blank?
+ add_role(Roles::USER) if roles.blank?
+ end
+
+ def synchronize_roles(new_roles)
+ return unless validate_roles(new_roles)
+
+ current_roles = roles.pluck(:name)
+
+ roles_to_remove = current_roles - new_roles
+ roles_to_remove.each do |role|
+ remove_role(role) || errors.add(:base, t('errors.messages.remove_role_failure', role: 'role'))
+ end
+
+ roles_to_add = new_roles - current_roles
+ roles_to_add.each do |role|
+ add_role(role) || errors.add(:base, t('errors.messages.add_role_failure', role: 'role'))
+ end
+ end
+
+ def validate_roles(new_roles)
+ valid_roles = Role.pluck(:name)
+ invalid_roles = new_roles - valid_roles
+ unless invalid_roles.empty?
+ errors.add(:base, t('errors.messages.invalid_roles_detected', invalid_roles: invalid_roles.join(', ')))
+ end
+
+ invalid_roles.empty?
+ end
+
+ def handle_unexpected_transaction_error(error)
+ errors.add(:base, t('errors.messages.unexpected_error'))
+ Rails.logger.error("Unexpected error while synchronizing roles: #{error.message}")
end
class Roles < User
@@ -59,5 +117,12 @@ class Roles < User
ROLES = [ADMIN, DOCTOR, HEALTH_COACH, USER].freeze
FULL_ACCESS_ROLES = [ADMIN, DOCTOR, HEALTH_COACH].freeze
+
+ ROLES_MAP = {
+ ADMIN => 'Admin',
+ DOCTOR => 'Doctor',
+ HEALTH_COACH => 'Health coach',
+ USER => 'User'
+ }.freeze
end
end
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
new file mode 100644
index 0000000..20025fc
--- /dev/null
+++ b/app/policies/user_policy.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+class UserPolicy < ApplicationPolicy
+ def index?
+ user.admin?
+ end
+
+ def show?
+ user.admin?
+ end
+
+ def create?
+ user.admin?
+ end
+
+ def update?
+ user.admin?
+ end
+
+ def destroy?
+ user.admin?
+ end
+
+ def edit_roles?
+ user.admin?
+ end
+
+ def update_roles?
+ user.admin?
+ end
+
+ class Scope < ApplicationPolicy::Scope
+ def resolve
+ if user.admin?
+ scope.all
+ else
+ scope.where(user: user)
+ end
+ end
+ end
+end
diff --git a/app/views/admin/users/_form.html.erb b/app/views/admin/users/_form.html.erb
new file mode 100644
index 0000000..10b2950
--- /dev/null
+++ b/app/views/admin/users/_form.html.erb
@@ -0,0 +1,32 @@
+<%= form_with model: @user, url: admin_user_path(@user), method: :patch do |form| %>
+ <% if @user.errors.any? %>
+
+
<%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved:
+
+
+ <% @user.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :first_name, style: "display: block" %>
+ <%= form.text_field :first_name %>
+
+
+
+ <%= form.label :last_name, style: "display: block" %>
+ <%= form.text_field :last_name %>
+
+
+
+ <%= form.label :email, style: "display: block" %>
+ <%= form.text_field :email %>
+
+
+
+ <%= form.submit class: "tw-my-4 tw-bg-blue-500 tw-hover:bg-blue-700 tw-text-white tw-font-bold tw-py-2 tw-px-4 tw-rounded" %>
+
+<% end %>
diff --git a/app/views/admin/users/_index_table_body.html.erb b/app/views/admin/users/_index_table_body.html.erb
new file mode 100644
index 0000000..774fe0a
--- /dev/null
+++ b/app/views/admin/users/_index_table_body.html.erb
@@ -0,0 +1,24 @@
+
+ <% @users.each do |user| %>
+
+
+ <%= user.full_name %>
+ |
+
+ <%= user.email %>
+ |
+
+ <%= get_user_roles_list_as_string user %>
+ |
+
+ <%= format_date_with_time user.created_at %>
+ |
+
+ <%= format_date_with_time user.updated_at %>
+ |
+
+ <%= link_to "Show details", admin_user_url(user), class: "tw-font-medium tw-text-blue-600 tw-dark:tw-text-blue-500 tw-hover:tw-underline" %>
+ |
+
+ <% end %>
+
diff --git a/app/views/admin/users/_index_table_head.html.erb b/app/views/admin/users/_index_table_head.html.erb
new file mode 100644
index 0000000..a2dc70b
--- /dev/null
+++ b/app/views/admin/users/_index_table_head.html.erb
@@ -0,0 +1,24 @@
+
+
+
+
+ User
+ |
+
+ Email
+ |
+
+ Role
+ |
+
+ Created date
+ |
+
+ Updated date
+ |
+
+ Show
+ |
+
+
+
\ No newline at end of file
diff --git a/app/views/admin/users/_user.html.erb b/app/views/admin/users/_user.html.erb
new file mode 100644
index 0000000..7c62222
--- /dev/null
+++ b/app/views/admin/users/_user.html.erb
@@ -0,0 +1,31 @@
+
+
+
+
+ User Name:
+ <%= user.full_name %>
+
+
+
+ Email:
+ <%= user.email %>
+
+
+
+ Role:
+ <%= get_user_roles_list_as_string user %>
+
+
+
+ Created at:
+ <%= format_date_with_time user.created_at %>
+
+
+
+ Updated at:
+ <%= format_date_with_time user.updated_at %>
+
+
+
+
+
diff --git a/app/views/admin/users/_user.json.jbuilder b/app/views/admin/users/_user.json.jbuilder
new file mode 100644
index 0000000..2ce5a95
--- /dev/null
+++ b/app/views/admin/users/_user.json.jbuilder
@@ -0,0 +1,4 @@
+# frozen_string_literal: true
+
+json.extract! user, :id, :first_name, :last_name, :email, :created_at, :updated_at
+json.url admin_user_url(user, format: :json)
diff --git a/app/views/admin/users/edit.html.erb b/app/views/admin/users/edit.html.erb
new file mode 100644
index 0000000..c6cda3f
--- /dev/null
+++ b/app/views/admin/users/edit.html.erb
@@ -0,0 +1,8 @@
+Editing user
+
+<%= render "form", user: @user %>
+
+
+ <%= link_to "Show this user", admin_user_url(@user) %> |
+ <%= link_to "Back to users", admin_users_path %>
+
diff --git a/app/views/admin/users/edit_roles.html.erb b/app/views/admin/users/edit_roles.html.erb
new file mode 100644
index 0000000..11f1993
--- /dev/null
+++ b/app/views/admin/users/edit_roles.html.erb
@@ -0,0 +1,33 @@
+Editing user roles
+
+<%= form_with model: @user, url: update_roles_admin_user_path(@user), method: :post do |form| %>
+ <% if @user.errors.any? %>
+
+
<%= pluralize(@user.errors.count, "error") %> prohibited this user from being saved:
+
+
+ <% @user.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :roles, style: "display: block" %>
+ <%= form.select :roles,
+ options_for_select(@roles, get_user_role_ids_list(@user)),
+ {},
+ multiple: true %>
+
+
+
+ <%= form.submit class: "tw-my-4 tw-bg-blue-500 tw-hover:bg-blue-700 tw-text-white tw-font-bold tw-py-2 tw-px-4 tw-rounded" %>
+
+<% end %>
+
+
+ <%= link_to "Show this user", admin_user_url(@user) %> |
+ <%= link_to "Back to users", admin_users_path %>
+
+
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb
new file mode 100644
index 0000000..0b04feb
--- /dev/null
+++ b/app/views/admin/users/index.html.erb
@@ -0,0 +1,13 @@
+
+
+
+
+
+ <%= render "index_table_head" %>
+ <%= render "index_table_body", users: @users %>
+
+
+
+
diff --git a/app/views/admin/users/index.json.jbuilder b/app/views/admin/users/index.json.jbuilder
new file mode 100644
index 0000000..2ff955e
--- /dev/null
+++ b/app/views/admin/users/index.json.jbuilder
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+json.array! @users, partial: 'users/user', as: :user
diff --git a/app/views/admin/users/show.html.erb b/app/views/admin/users/show.html.erb
new file mode 100644
index 0000000..eb66256
--- /dev/null
+++ b/app/views/admin/users/show.html.erb
@@ -0,0 +1,15 @@
+<%= render "user", user: @user %>
+
+
+ <%= link_to "Edit this user", edit_admin_user_path(@user) %> |
+ <%= link_to "Change this user roles", edit_roles_admin_user_path(@user) %> |
+ <%= link_to "Back to users", admin_users_path %>
+
+ <%= button_to(
+ "Remove this user",
+ edit_admin_user_path(@user),
+ method: :delete,
+ data: { turbo_method: :delete, turbo_confirm: "Are you sure?" },
+ class: "tw-mt-6 tw-mr-6 tw-mb-6 tw-bg-red-500 tw-hover:bg-blue-700 tw-text-white tw-font-bold tw-py-2 tw-px-4 tw-rounded"
+ ) %>
+
diff --git a/app/views/admin/users/show.json.jbuilder b/app/views/admin/users/show.json.jbuilder
new file mode 100644
index 0000000..48cca18
--- /dev/null
+++ b/app/views/admin/users/show.json.jbuilder
@@ -0,0 +1,3 @@
+# frozen_string_literal: true
+
+json.partial! 'users/user', user: @user
diff --git a/app/views/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb
index 793092e..01896dc 100644
--- a/app/views/layouts/_navbar.html.erb
+++ b/app/views/layouts/_navbar.html.erb
@@ -21,6 +21,11 @@
<%= link_to "Biomarkers", biomarkers_path, class: "nav-link #{current_page?(biomarkers_path) ? 'active' : ''}" %>
+
+ <% if current_user&.admin? %>
+ <%= link_to "Users", admin_users_path, class: "nav-link #{current_page?(admin_users_path) ? 'active' : ''}" %>
+ <% end %>
+
<% if user_signed_in? %>
Welcome <%= current_user.full_name %>
diff --git a/config/application.rb b/config/application.rb
index 821cbb6..fab9667 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -40,5 +40,9 @@ class Application < Rails::Application
# Don't generate system test files.
config.generators.system_tests = nil
+
+ # Fixes broken tailwind.css build for CI
+ # @see https://github.com/rails/tailwindcss-rails/issues/153#issuecomment-1225895063
+ config.assets.css_compressor = nil
end
end
diff --git a/config/locales/en.yml b/config/locales/en.yml
index fd727f4..7d261de 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -62,7 +62,22 @@ en:
success: "Lab test was successfully updated."
destroy:
success: "Lab test was successfully removed."
+ admin:
+ users:
+ update:
+ success: "User was successfully updated."
+ destroy:
+ success: "User was successfully removed."
+ update_roles:
+ success: "User roles were successfully updated."
application:
user_not_authorized:
failure: 'You are not authorized to perform this action.'
+
+ errors:
+ messages:
+ invalid_roles_detected: "Invalid roles detected: %{invalid_roles}"
+ remove_role_failure: "Failed to remove role: %{role}"
+ add_role_failure: "Failed to add role: %{role}"
+ unexpected_error: "Unexpected error while synchronizing roles"
diff --git a/config/routes.rb b/config/routes.rb
index 5efdc48..87b6d42 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -9,6 +9,14 @@
resources :measurements
resources :lab_tests
resources :health_records
+ namespace :admin do
+ resources :users, only: %i[index show edit update destroy] do
+ member do
+ get :edit_roles
+ post :update_roles
+ end
+ end
+ end
devise_for :users
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
diff --git a/db/migrate/20241230074802_add_unique_index_to_roles_name.rb b/db/migrate/20241230074802_add_unique_index_to_roles_name.rb
new file mode 100644
index 0000000..4ec54d2
--- /dev/null
+++ b/db/migrate/20241230074802_add_unique_index_to_roles_name.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+class AddUniqueIndexToRolesName < ActiveRecord::Migration[7.1]
+ def change
+ add_index :roles, :name, unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 561b22c..c74842f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2024_11_26_065249) do
+ActiveRecord::Schema[7.1].define(version: 2024_12_30_074802) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -77,6 +77,7 @@
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name", "resource_type", "resource_id"], name: "index_roles_on_name_and_resource_type_and_resource_id"
+ t.index ["name"], name: "index_roles_on_name", unique: true
t.index ["resource_type", "resource_id"], name: "index_roles_on_resource"
end
diff --git a/docker-compose.override.yml b/docker-compose.override.yml
new file mode 100644
index 0000000..972b446
--- /dev/null
+++ b/docker-compose.override.yml
@@ -0,0 +1,8 @@
+services:
+ health-keeper-app:
+ volumes:
+ - ./:/app
+ - bundle_cache:/usr/local/bundle
+
+volumes:
+ bundle_cache:
diff --git a/docker-compose.yml b/docker-compose.yml
index 79de2d1..65bc163 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,9 +5,6 @@ services:
- '3000:3000'
depends_on:
- health-keeper-postgres
- volumes:
- - ./:/app
- - gem_cache:/usr/local/bundle/gems
tty: true
health-keeper-postgres:
@@ -23,4 +20,3 @@ services:
volumes:
health-keeper-postgres:
- gem_cache:
diff --git a/spec/factories/roles.rb b/spec/factories/roles.rb
new file mode 100644
index 0000000..8d697f7
--- /dev/null
+++ b/spec/factories/roles.rb
@@ -0,0 +1,7 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :role do |f|
+ f.name { Faker::Job.unique.position }
+ end
+end
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
new file mode 100644
index 0000000..4855f02
--- /dev/null
+++ b/spec/factories/users.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :user do |f|
+ f.first_name { Faker::Name.first_name }
+ f.last_name { Faker::Name.last_name }
+ f.email { Faker::Internet.unique.email }
+ f.password { 'password' }
+ trait :admin do
+ after(:create) { |user| user.add_role(:admin) }
+ end
+ end
+end
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
new file mode 100644
index 0000000..0f1c418
--- /dev/null
+++ b/spec/helpers/users_helper_spec.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe UsersHelper do
+ let(:user) { instance_double(User, user_roles_names: %w[admin doctor health_coach]) }
+
+ describe '#get_user_roles_list_as_string' do
+ context 'when the user has valid roles' do
+ it 'returns a comma-separated string of role names' do
+ result = helper.get_user_roles_list_as_string(user)
+ expect(result).to eq('Admin, Doctor, Health coach')
+ end
+ end
+
+ context 'when the user has no roles' do
+ let(:user) { instance_double(User, user_roles_names: []) }
+
+ it 'returns an empty string' do
+ result = helper.get_user_roles_list_as_string(user)
+ expect(result).to eq('')
+ end
+ end
+
+ context 'when the user has invalid roles' do
+ let(:user) { instance_double(User, user_roles_names: %w[invalid_role]) }
+
+ it 'returns nil for invalid roles' do
+ result = helper.get_user_roles_list_as_string(user)
+ expect(result).to eq('')
+ end
+ end
+ end
+
+ describe '#get_user_role_ids_list' do
+ context 'when the user has roles' do
+ it 'returns the list of role IDs' do
+ result = helper.get_user_role_ids_list(user)
+ expect(result).to eq(%w[admin doctor health_coach])
+ end
+ end
+
+ context 'when the user has no roles' do
+ let(:user) { instance_double(User, user_roles_names: []) }
+
+ it 'returns an empty array' do
+ result = helper.get_user_role_ids_list(user)
+ expect(result).to eq([])
+ end
+ end
+
+ context 'when the user has invalid roles' do
+ let(:user) { instance_double(User, user_roles_names: %w[invalid_role]) }
+
+ it 'returns the invalid roles as-is' do
+ result = helper.get_user_role_ids_list(user)
+ expect(result).to eq(['invalid_role'])
+ end
+ end
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index 99ebdaf..fdd0294 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -8,6 +8,7 @@
abort('The Rails environment is running in production mode!') if Rails.env.production?
require 'rspec/rails'
# Add additional requires below this line. Rails is not loaded until this point!
+require 'devise'
# Requires supporting ruby files with custom matchers and macros, etc, in
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
@@ -65,3 +66,7 @@
# arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name")
end
+
+RSpec.configure do |config|
+ config.include Devise::Test::IntegrationHelpers, type: :request
+end
diff --git a/spec/requests/admin/users_spec.rb b/spec/requests/admin/users_spec.rb
new file mode 100644
index 0000000..0d3b6c6
--- /dev/null
+++ b/spec/requests/admin/users_spec.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Admin::UsersController' do
+ let(:admin) { create(:user, :admin) }
+ let(:user) { create(:user) }
+ let(:valid_attributes) { { first_name: 'John', last_name: 'Doe', email: 'john.doe@example.com' } }
+ let(:invalid_attributes) { { email: '' } }
+ let(:roles) { %w[admin doctor user] }
+
+ before do
+ sign_in admin
+ end
+
+ describe 'GET /index' do
+ it 'renders a successful response' do
+ get admin_users_path
+ expect(response).to be_successful
+ end
+
+ it 'authorizes and displays all users' do
+ users_amount = 3
+ create_list(:user, users_amount)
+ get admin_users_path
+ expect(assigns(:users).count).to eq(users_amount + 1) # Include the admin user
+ end
+ end
+
+ describe 'GET /show' do
+ it 'renders the user details' do
+ get admin_user_path(user)
+ expect(response).to be_successful
+ end
+ end
+
+ describe 'GET /edit' do
+ it 'renders the edit form' do
+ get edit_admin_user_path(user)
+ expect(response).to be_successful
+ end
+ end
+
+ describe 'PATCH /update' do
+ context 'with valid parameters' do
+ it 'redirects to the user page' do
+ patch admin_user_path(user), params: { user: valid_attributes }
+ expect(response).to redirect_to(admin_user_path(user))
+ end
+
+ it 'updates the user attributes' do
+ patch admin_user_path(user), params: { user: valid_attributes }
+ expect(user.reload.first_name).to eq('John')
+ end
+ end
+
+ context 'with invalid parameters' do
+ it 'renders the edit form with errors' do
+ patch admin_user_path(user), params: { user: invalid_attributes }
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+ end
+ end
+
+ describe 'DELETE /destroy' do
+ it 'deletes the user' do
+ user_to_delete = create(:user)
+ expect do
+ delete admin_user_path(user_to_delete)
+ end.to change(User, :count).by(-1)
+ end
+
+ it 'redirects to the users index' do
+ user_to_delete = create(:user)
+ delete admin_user_path(user_to_delete)
+ expect(response).to redirect_to(admin_users_path)
+ end
+ end
+
+ describe 'GET /edit_roles' do
+ it 'renders the edit roles form' do
+ get edit_roles_admin_user_path(user)
+ expect(response).to be_successful
+ end
+
+ it 'assigns roles list' do
+ roles.each { |role| Role.find_or_create_by!(name: role) } # Prevent duplicate role creation
+ get edit_roles_admin_user_path(user)
+ expect(assigns(:roles)).to match_array(roles.map { |r| [r.capitalize, r] })
+ end
+ end
+
+ describe 'POST /update_roles' do
+ context 'with valid roles' do
+ it 'redirects to the user page' do
+ roles.each { |role| Role.find_or_create_by!(name: role) }
+ post update_roles_admin_user_path(user), params: { user: { roles: %w[admin doctor] } }
+ expect(response).to redirect_to(admin_user_path(user))
+ end
+
+ it 'updates the user roles' do
+ roles.each { |role| Role.find_or_create_by!(name: role) }
+ post update_roles_admin_user_path(user), params: { user: { roles: %w[admin doctor] } }
+ expect(user.reload.roles.map(&:name)).to include('admin', 'doctor')
+ end
+ end
+
+ context 'with invalid roles' do
+ it 'returns an unprocessable entity status' do
+ post update_roles_admin_user_path(user), params: { user: { roles: %w[invalid_role] } }
+ expect(response).to have_http_status(:unprocessable_entity)
+ end
+
+ it 'assigns errors to the user object' do
+ post update_roles_admin_user_path(user), params: { user: { roles: %w[invalid_role] } }
+ expect(assigns(:user).errors[:base]).to include('Invalid roles detected: invalid_role')
+ end
+ end
+ end
+end