hr_lite 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +112 -1
- data/README.md +67 -31
- data/app/controllers/hr_lite/admin/attendances_controller.rb +21 -6
- data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
- data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
- data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -5
- data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
- data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
- data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
- data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
- data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
- data/app/controllers/hr_lite/admin/salary_slips_controller.rb +5 -6
- data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
- data/app/controllers/hr_lite/application_controller.rb +39 -1
- data/app/helpers/hr_lite/application_helper.rb +5 -2
- data/app/models/hr_lite/audit_log.rb +26 -1
- data/app/models/hr_lite/comp_off_request.rb +3 -1
- data/app/models/hr_lite/leave_request.rb +27 -4
- data/app/models/hr_lite/payroll_run.rb +30 -4
- data/app/models/hr_lite/regularization_request.rb +3 -4
- data/app/models/hr_lite/role.rb +66 -0
- data/app/models/hr_lite/role_assignment.rb +18 -0
- data/app/models/hr_lite/role_grant.rb +15 -0
- data/app/models/hr_lite/salary_slip.rb +1 -2
- data/app/services/hr_lite/access.rb +111 -0
- data/app/services/hr_lite/payroll_run_processor.rb +4 -1
- data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
- data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
- data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
- data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
- data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
- data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
- data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
- data/lib/hr_lite/configuration.rb +11 -7
- data/lib/hr_lite/current.rb +4 -0
- data/lib/hr_lite/financial_year.rb +26 -0
- data/lib/hr_lite/permissions.rb +79 -0
- data/lib/hr_lite/role_seeds.rb +81 -0
- data/lib/hr_lite/statutory_rate_card.rb +45 -5
- data/lib/hr_lite/version.rb +1 -1
- data/lib/hr_lite.rb +81 -11
- data/lib/tasks/hr_lite_tasks.rake +10 -2
- metadata +17 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<% content_for(:page_title) { @role.name } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title"><%= @role.name %></h1>
|
|
4
|
+
<% unless @role.system? %>
|
|
5
|
+
<div class="hrl-page__actions">
|
|
6
|
+
<%= button_to "Delete role", hr_lite.admin_role_path(@role), method: :delete,
|
|
7
|
+
class: "hrl-btn hrl-btn--danger",
|
|
8
|
+
form: { data: { turbo_confirm: "Delete #{@role.name}? Everyone holding it loses that access." } } %>
|
|
9
|
+
</div>
|
|
10
|
+
<% end %>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<%= render "form", role: @role, url: hr_lite.admin_role_path(@role), method: :patch %>
|
|
14
|
+
|
|
15
|
+
<section class="hrl-card">
|
|
16
|
+
<h2 class="hrl-card__title">Who holds this role</h2>
|
|
17
|
+
<% assignments = @role.role_assignments.includes(:user).sort_by { |a| hr_display_name(a.user).downcase } %>
|
|
18
|
+
<% if assignments.empty? %>
|
|
19
|
+
<p class="hrl-muted">Nobody yet.</p>
|
|
20
|
+
<% else %>
|
|
21
|
+
<div class="hrl-table-wrap">
|
|
22
|
+
<table class="hrl-table hrl-table--stack">
|
|
23
|
+
<thead><tr><th>Person</th><th>Added</th><th></th></tr></thead>
|
|
24
|
+
<tbody>
|
|
25
|
+
<% assignments.each do |assignment| %>
|
|
26
|
+
<tr>
|
|
27
|
+
<td data-label="Person"><%= hr_display_name(assignment.user) %></td>
|
|
28
|
+
<td data-label="Added"><%= assignment.created_at.to_date.strftime("%d %b %Y") %></td>
|
|
29
|
+
<td data-label="">
|
|
30
|
+
<%= button_to "Remove", hr_lite.admin_role_assignment_path(@role, assignment),
|
|
31
|
+
method: :delete, class: "hrl-btn",
|
|
32
|
+
form: { data: { turbo_confirm: "Remove #{hr_display_name(assignment.user)} from #{@role.name}?" } } %>
|
|
33
|
+
</td>
|
|
34
|
+
</tr>
|
|
35
|
+
<% end %>
|
|
36
|
+
</tbody>
|
|
37
|
+
</table>
|
|
38
|
+
</div>
|
|
39
|
+
<% end %>
|
|
40
|
+
|
|
41
|
+
<%= form_with url: hr_lite.admin_role_assignments_path(@role), method: :post, local: true do %>
|
|
42
|
+
<div class="hrl-field">
|
|
43
|
+
<%= label_tag :user_id, "Add somebody to this role" %>
|
|
44
|
+
<% held = @role.role_assignments.map(&:user_id) %>
|
|
45
|
+
<%= select_tag :user_id,
|
|
46
|
+
options_for_select(HrLite.employees.reject { |u| held.include?(u.id) }
|
|
47
|
+
.map { |u| [ hr_display_name(u), u.id ] }) %>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="hrl-form-actions">
|
|
50
|
+
<%= submit_tag "Add", class: "hrl-btn hrl-btn--primary" %>
|
|
51
|
+
</div>
|
|
52
|
+
<% end %>
|
|
53
|
+
</section>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<% content_for(:page_title) { "Roles" } %>
|
|
2
|
+
<div class="hrl-page__head">
|
|
3
|
+
<h1 class="hrl-page__title">Roles & permissions</h1>
|
|
4
|
+
<div class="hrl-page__actions">
|
|
5
|
+
<a class="hrl-btn hrl-btn--primary" href="<%= hr_lite.new_admin_role_path %>">Add role</a>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<section class="hrl-card">
|
|
10
|
+
<p class="hrl-muted hrl-small">
|
|
11
|
+
A permission is what somebody may do; its scope is whose records they may
|
|
12
|
+
do it to — only their own, their reports', or everyone's.
|
|
13
|
+
</p>
|
|
14
|
+
<div class="hrl-table-wrap">
|
|
15
|
+
<table class="hrl-table hrl-table--stack">
|
|
16
|
+
<thead><tr><th>Role</th><th>People</th><th>Reaches</th><th></th></tr></thead>
|
|
17
|
+
<tbody>
|
|
18
|
+
<% @roles.each do |role| %>
|
|
19
|
+
<tr>
|
|
20
|
+
<td data-label="Role">
|
|
21
|
+
<strong><%= role.name %></strong>
|
|
22
|
+
<% if role.system? %><span class="hrl-badge hrl-badge--muted">Built-in</span><% end %>
|
|
23
|
+
<% if role.description.present? %>
|
|
24
|
+
<div class="hrl-small hrl-muted"><%= role.description %></div>
|
|
25
|
+
<% end %>
|
|
26
|
+
</td>
|
|
27
|
+
<td data-label="People" class="hrl-num"><%= role.role_assignments.size %></td>
|
|
28
|
+
<td data-label="Reaches">
|
|
29
|
+
<% company_wide = role.role_grants.count { |g| g.scope == "all" } %>
|
|
30
|
+
<% team = role.role_grants.count { |g| g.scope == "team" } %>
|
|
31
|
+
<%= role.role_grants.size %> permission<%= "s" unless role.role_grants.size == 1 %>
|
|
32
|
+
<%# Joined rather than concatenated: a role with team grants and
|
|
33
|
+
no company-wide ones rendered a leading "·" with nothing
|
|
34
|
+
in front of it. %>
|
|
35
|
+
<% reach = [ ("#{company_wide} company-wide" if company_wide.positive?),
|
|
36
|
+
("#{team} own team" if team.positive?) ].compact %>
|
|
37
|
+
<% if reach.any? %>
|
|
38
|
+
<span class="hrl-small hrl-muted"><%= reach.join(" · ") %></span>
|
|
39
|
+
<% end %>
|
|
40
|
+
</td>
|
|
41
|
+
<td data-label="">
|
|
42
|
+
<a class="hrl-small" href="<%= hr_lite.edit_admin_role_path(role) %>">Edit</a>
|
|
43
|
+
</td>
|
|
44
|
+
</tr>
|
|
45
|
+
<% end %>
|
|
46
|
+
</tbody>
|
|
47
|
+
</table>
|
|
48
|
+
</div>
|
|
49
|
+
</section>
|
data/config/routes.rb
CHANGED
|
@@ -46,6 +46,9 @@ HrLite::Engine.routes.draw do
|
|
|
46
46
|
resources :regularization_requests, only: %i[index show] do
|
|
47
47
|
member { post :approve; post :reject }
|
|
48
48
|
end
|
|
49
|
+
resources :roles, except: :show do
|
|
50
|
+
resources :assignments, only: %i[create destroy], controller: "role_assignments"
|
|
51
|
+
end
|
|
49
52
|
resources :leave_types, except: :show
|
|
50
53
|
resources :office_locations, except: :show
|
|
51
54
|
resources :holidays, only: %i[index create update destroy] do
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
class AddStatusConstraintsAndAppraisalFkToHrLite < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Every status column in the engine is a plain string validated only in
|
|
3
|
+
# Ruby, so anything that skips validations — update_column, update_all,
|
|
4
|
+
# insert_all, a console fix, a future migration — can write a value no
|
|
5
|
+
# screen renders and no transition accepts. These constraints put the
|
|
6
|
+
# allowed set somewhere it cannot be bypassed.
|
|
7
|
+
#
|
|
8
|
+
# Deliberately in step with each model's own STATUSES array: adding a
|
|
9
|
+
# status now means writing a migration, which is the point.
|
|
10
|
+
STATUSES = {
|
|
11
|
+
hr_lite_attendance_records: %w[present half_day],
|
|
12
|
+
hr_lite_leave_requests: %w[pending approved rejected cancelled],
|
|
13
|
+
hr_lite_comp_off_requests: %w[pending approved rejected cancelled],
|
|
14
|
+
hr_lite_regularization_requests: %w[pending approved rejected cancelled],
|
|
15
|
+
hr_lite_resignations: %w[pending accepted withdrawn],
|
|
16
|
+
hr_lite_appraisals: %w[draft shared],
|
|
17
|
+
hr_lite_payroll_runs: %w[draft processing review finalized published]
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def up
|
|
21
|
+
# On an install that already holds an off-list value the constraint build
|
|
22
|
+
# fails with a bare integrity error naming neither the row nor the value.
|
|
23
|
+
# Say what is actually in the way instead — this runs on live data.
|
|
24
|
+
offenders = STATUSES.filter_map do |table, statuses|
|
|
25
|
+
found = select_values(
|
|
26
|
+
"SELECT DISTINCT status FROM #{table} WHERE status NOT IN (#{quoted(statuses)})"
|
|
27
|
+
)
|
|
28
|
+
"#{table}: #{found.join(', ')}" if found.any?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
if offenders.any?
|
|
32
|
+
raise ActiveRecord::IrreversibleMigration,
|
|
33
|
+
"Cannot constrain status columns — these rows hold values no model " \
|
|
34
|
+
"declares. Correct them first:\n #{offenders.join("\n ")}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
STATUSES.each do |table, statuses|
|
|
38
|
+
add_check_constraint table, status_sql(statuses), name: constraint_name(table)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# has_one :designation_change, dependent: :nullify — with neither an index
|
|
42
|
+
# nor a foreign key. Destroying an appraisal scanned the whole table to
|
|
43
|
+
# find its promotion row, and nothing stopped that row from being left
|
|
44
|
+
# pointing at an appraisal that no longer exists.
|
|
45
|
+
add_index :hr_lite_designation_changes, :appraisal_id
|
|
46
|
+
add_foreign_key :hr_lite_designation_changes, :hr_lite_appraisals,
|
|
47
|
+
column: :appraisal_id, on_delete: :nullify
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def down
|
|
51
|
+
remove_foreign_key :hr_lite_designation_changes, column: :appraisal_id
|
|
52
|
+
remove_index :hr_lite_designation_changes, :appraisal_id
|
|
53
|
+
STATUSES.each_key { |table| remove_check_constraint table, name: constraint_name(table) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def status_sql(statuses) = "status IN (#{quoted(statuses)})"
|
|
59
|
+
|
|
60
|
+
def quoted(statuses) = statuses.map { |s| connection.quote(s) }.join(", ")
|
|
61
|
+
|
|
62
|
+
def constraint_name(table) = "#{table}_status_check"
|
|
63
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
class CreateHrLiteRolesAndGrants < ActiveRecord::Migration[8.1]
|
|
2
|
+
# Roles replace the three configured lambdas (admin_check, leadership_emails,
|
|
3
|
+
# superadmin_emails). Two of those matched a MUTABLE email string — a
|
|
4
|
+
# permission stored in a column the host lets people edit.
|
|
5
|
+
#
|
|
6
|
+
# Permissions themselves are not a table: the vocabulary is declared in
|
|
7
|
+
# HrLite::Permissions::REGISTRY, so a grant is a (role, key, scope) row and
|
|
8
|
+
# an unknown key cannot be stored. That keeps seeding idempotent, and makes
|
|
9
|
+
# retiring a permission a code change with a spec rather than silent data.
|
|
10
|
+
def change
|
|
11
|
+
create_table :hr_lite_roles do |t|
|
|
12
|
+
t.string :name, null: false
|
|
13
|
+
t.string :description
|
|
14
|
+
# Roles the engine seeds and refers to by name. Renaming or deleting one
|
|
15
|
+
# is refused by the model — the upgrade path off the email lists lands
|
|
16
|
+
# people in these, and the roles screen has to keep meaning something.
|
|
17
|
+
t.boolean :system, null: false, default: false
|
|
18
|
+
t.timestamps
|
|
19
|
+
end
|
|
20
|
+
add_index :hr_lite_roles, :name, unique: true
|
|
21
|
+
|
|
22
|
+
create_table :hr_lite_role_grants do |t|
|
|
23
|
+
t.references :role, null: false, index: false,
|
|
24
|
+
foreign_key: { to_table: :hr_lite_roles, on_delete: :cascade }
|
|
25
|
+
t.string :permission_key, null: false
|
|
26
|
+
# self | team | all — see HrLite::Permissions. Stored per grant so one
|
|
27
|
+
# key covers "own leave", "my reports' leave" and "everyone's leave".
|
|
28
|
+
t.string :scope, null: false, default: "self"
|
|
29
|
+
t.timestamps
|
|
30
|
+
end
|
|
31
|
+
# One scope per (role, permission): a role either reaches a set of rows or
|
|
32
|
+
# it does not, and two rows for one key would make that a lookup race.
|
|
33
|
+
add_index :hr_lite_role_grants, %i[role_id permission_key], unique: true
|
|
34
|
+
add_check_constraint :hr_lite_role_grants, "scope IN ('self', 'team', 'all')",
|
|
35
|
+
name: "hr_lite_role_grants_scope_check"
|
|
36
|
+
|
|
37
|
+
create_table :hr_lite_role_assignments do |t|
|
|
38
|
+
# No FK to the host user table — its name is host-specific, the same
|
|
39
|
+
# reason every other user_id in this engine is a bare bigint.
|
|
40
|
+
t.bigint :user_id, null: false
|
|
41
|
+
t.references :role, null: false, index: false,
|
|
42
|
+
foreign_key: { to_table: :hr_lite_roles, on_delete: :cascade }
|
|
43
|
+
t.bigint :granted_by_id
|
|
44
|
+
t.timestamps
|
|
45
|
+
end
|
|
46
|
+
add_index :hr_lite_role_assignments, %i[user_id role_id], unique: true
|
|
47
|
+
add_index :hr_lite_role_assignments, :role_id
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
class SeedHrLiteRolesFromEmailTiers < ActiveRecord::Migration[8.1]
|
|
2
|
+
# The upgrade path off the email lists. On an install that is already
|
|
3
|
+
# running, this is the migration that decides whether anyone loses access,
|
|
4
|
+
# so it is deliberately generous: everybody who could reach something
|
|
5
|
+
# before can reach at least as much afterwards.
|
|
6
|
+
#
|
|
7
|
+
# config.superadmin_emails -> Super Admin
|
|
8
|
+
# config.leadership_emails -> Leadership
|
|
9
|
+
# admin_check == true -> HR
|
|
10
|
+
# everybody else -> Employee
|
|
11
|
+
#
|
|
12
|
+
# It reads the host's OWN configuration, so nothing has to be typed twice,
|
|
13
|
+
# and it says out loud what it did — an upgrade that silently grants the
|
|
14
|
+
# money tier to the wrong person is the failure worth being loud about.
|
|
15
|
+
def up
|
|
16
|
+
require "hr_lite/role_seeds"
|
|
17
|
+
created = HrLite::RoleSeeds.call
|
|
18
|
+
say "Seeded roles: #{created.join(', ')}" if created.any?
|
|
19
|
+
|
|
20
|
+
# A host that has already assigned roles by hand is not re-derived from
|
|
21
|
+
# email lists — that would undo their work.
|
|
22
|
+
if HrLite::RoleAssignment.exists?
|
|
23
|
+
say "Role assignments already exist — leaving them alone."
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
roles = HrLite::Role.where(name: [
|
|
28
|
+
HrLite::Role::EMPLOYEE, HrLite::Role::HR,
|
|
29
|
+
HrLite::Role::LEADERSHIP, HrLite::Role::SUPER_ADMIN
|
|
30
|
+
]).index_by(&:name)
|
|
31
|
+
|
|
32
|
+
# The legacy predicates, read directly rather than through HrLite.admin?
|
|
33
|
+
# — which by now answers off roles, and would say no to everyone.
|
|
34
|
+
superadmins = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
|
|
35
|
+
leaders = HrLite.normalize_email_list(HrLite.config.leadership_emails)
|
|
36
|
+
# Pre-0.5.0 behaviour, still live on hosts that never set the money list:
|
|
37
|
+
# an empty superadmin list meant "the same people as leadership".
|
|
38
|
+
superadmins = leaders if superadmins.empty?
|
|
39
|
+
|
|
40
|
+
assigned = Hash.new { |hash, key| hash[key] = [] }
|
|
41
|
+
|
|
42
|
+
HrLite.config.employees_scope.call.find_each do |user|
|
|
43
|
+
address = user.respond_to?(:email) ? user.email.to_s.downcase.strip : ""
|
|
44
|
+
names = [ HrLite::Role::EMPLOYEE ]
|
|
45
|
+
names << HrLite::Role::SUPER_ADMIN if address.present? && superadmins.include?(address)
|
|
46
|
+
names << HrLite::Role::LEADERSHIP if address.present? && leaders.include?(address)
|
|
47
|
+
names << HrLite::Role::HR if legacy_admin?(user)
|
|
48
|
+
|
|
49
|
+
names.uniq.each do |name|
|
|
50
|
+
role = roles[name] or next
|
|
51
|
+
|
|
52
|
+
HrLite::RoleAssignment.create!(user_id: user.id, role: role)
|
|
53
|
+
assigned[name] << HrLite.display_name(user)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
if assigned.empty?
|
|
58
|
+
say "No users matched employees_scope — assign roles from the Roles screen."
|
|
59
|
+
else
|
|
60
|
+
assigned.each { |name, people| say "#{name}: #{people.sort.join(', ')}" }
|
|
61
|
+
end
|
|
62
|
+
say "Set `config.legacy_tier_checks = true` to keep the old email lists in " \
|
|
63
|
+
"charge for now; it is honoured until 0.7.0."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Assignments only — the roles themselves stay, because dropping them would
|
|
67
|
+
# take every hand-made grant with them.
|
|
68
|
+
def down
|
|
69
|
+
HrLite::RoleAssignment.delete_all
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# admin_check is a host lambda over the host's own user model; a host whose
|
|
75
|
+
# users do not answer it must not break the whole upgrade.
|
|
76
|
+
def legacy_admin?(user)
|
|
77
|
+
!!HrLite.config.admin_check.call(user)
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
say "admin_check raised for #{HrLite.display_name(user)} (#{e.class}) — treated as not HR."
|
|
80
|
+
false
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -7,13 +7,19 @@ HrLite.configure do |c|
|
|
|
7
7
|
c.current_user_method = :current_user
|
|
8
8
|
c.authenticate_method = :authenticate_user!
|
|
9
9
|
|
|
10
|
-
#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
|
|
10
|
+
# Access is a role table, not configuration — `rake hr_lite:seed` creates
|
|
11
|
+
# the built-in roles and you assign people from /admin/roles. The first
|
|
12
|
+
# person needs Super Admin, which the upgrade migration grants on an
|
|
13
|
+
# existing install; on a fresh one, from a console:
|
|
14
|
+
#
|
|
15
|
+
# HrLite::RoleAssignment.create!(
|
|
16
|
+
# user_id: User.find_by!(email: "you@example.com").id,
|
|
17
|
+
# role: HrLite::Role.find_by!(name: HrLite::Role::SUPER_ADMIN))
|
|
18
|
+
#
|
|
19
|
+
# Upgrading from pre-0.6.0 and not ready to move? Uncomment this and keep
|
|
20
|
+
# your leadership_emails / superadmin_emails / admin_check as they were.
|
|
21
|
+
# It is honoured until 0.7.0.
|
|
22
|
+
# c.legacy_tier_checks = true
|
|
17
23
|
|
|
18
24
|
# Where the portal is reachable (subdomain or path). Enables email link
|
|
19
25
|
# buttons and HrLite.public_url / HrLite.public_url? for deep links.
|
|
@@ -10,7 +10,8 @@ module HrLite
|
|
|
10
10
|
:leadership_emails, :leadership_check, :extra_stylesheets,
|
|
11
11
|
:superadmin_emails, :superadmin_check,
|
|
12
12
|
:mailer_from, :public_url_base, :notification_matrix, :back_link,
|
|
13
|
-
:onboard_user, :offboard_user, :invite_url_for
|
|
13
|
+
:onboard_user, :offboard_user, :invite_url_for,
|
|
14
|
+
:legacy_tier_checks
|
|
14
15
|
|
|
15
16
|
attr_reader :leave_year_start_month
|
|
16
17
|
|
|
@@ -45,17 +46,20 @@ module HrLite
|
|
|
45
46
|
@time_zone = "Asia/Kolkata"
|
|
46
47
|
@currency_symbol = "₹"
|
|
47
48
|
@on_designation_change = ->(user, designation) { }
|
|
49
|
+
# Access came from three lambdas before 0.6.0, two of which matched the
|
|
50
|
+
# user's EMAIL — a mutable, host-owned, unverified column that a host
|
|
51
|
+
# then had to remember never to let anyone edit. Roles replace them.
|
|
52
|
+
# This flag hands authority back to the old lambdas for a host that has
|
|
53
|
+
# not finished migrating; honoured for one minor version, gone in 0.7.0.
|
|
54
|
+
@legacy_tier_checks = false
|
|
48
55
|
@leadership_emails = []
|
|
49
|
-
@leadership_check = ->(user)
|
|
50
|
-
emails = HrLite.config.leadership_emails.map { |e| e.to_s.downcase.strip }
|
|
51
|
-
emails.include?(user.email.to_s.downcase)
|
|
52
|
-
end
|
|
56
|
+
@leadership_check = ->(user) { HrLite.email_listed?(user, HrLite.config.leadership_emails) }
|
|
53
57
|
# Money tier: salary structures, payroll, slips, appraisals. Empty
|
|
54
58
|
# list means "same as leadership" (pre-0.5.0 behaviour).
|
|
55
59
|
@superadmin_emails = []
|
|
56
60
|
@superadmin_check = ->(user) do
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
list = HrLite.normalize_email_list(HrLite.config.superadmin_emails)
|
|
62
|
+
list.empty? ? HrLite.leadership?(user) : HrLite.email_listed?(user, list)
|
|
59
63
|
end
|
|
60
64
|
@extra_stylesheets = [] # host stylesheets linked AFTER hr_lite.css (CSS-var overrides)
|
|
61
65
|
@mailer_from = "hr@example.com"
|
data/lib/hr_lite/current.rb
CHANGED
|
@@ -3,5 +3,9 @@ module HrLite
|
|
|
3
3
|
# readable from any model callback without threading it through.
|
|
4
4
|
class Current < ActiveSupport::CurrentAttributes
|
|
5
5
|
attribute :actor
|
|
6
|
+
# Resolved permissions, keyed by user id. Every screen asks several times
|
|
7
|
+
# per request and resolution joins three tables; CurrentAttributes is
|
|
8
|
+
# reset between requests, so a role change takes effect on the next one.
|
|
9
|
+
attribute :access_cache
|
|
6
10
|
end
|
|
7
11
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The Indian financial year runs 1 April – 31 March. Three places worked
|
|
3
|
+
# this out for themselves (the slip's year-to-date sums, the TDS projector
|
|
4
|
+
# and the rate card); it lives here once so they cannot drift apart.
|
|
5
|
+
module FinancialYear
|
|
6
|
+
START_MONTH = 4
|
|
7
|
+
|
|
8
|
+
# First day of the FY containing `date`. March 2027 belongs to the year
|
|
9
|
+
# that opened on 1 April 2026.
|
|
10
|
+
def self.start_for(date)
|
|
11
|
+
year = date.month >= START_MONTH ? date.year : date.year - 1
|
|
12
|
+
Date.new(year, START_MONTH, 1)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# "2026-27" — for warnings and screens, never for arithmetic.
|
|
16
|
+
def self.label(date)
|
|
17
|
+
start = start_for(date)
|
|
18
|
+
"#{start.year}-#{format('%02d', (start.year + 1) % 100)}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# True when `date` falls in an earlier FY than `other`.
|
|
22
|
+
def self.before?(date, other)
|
|
23
|
+
start_for(date) < start_for(other)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The whole vocabulary of the engine's authorization, in one place.
|
|
3
|
+
#
|
|
4
|
+
# A permission is a KEY plus a SCOPE. The key says what the action is
|
|
5
|
+
# ("approve leave"); the scope says whose rows it reaches:
|
|
6
|
+
#
|
|
7
|
+
# self — only this person's own records
|
|
8
|
+
# team — the people who report to them (EmployeeProfile#manager_id)
|
|
9
|
+
# all — everyone in the company
|
|
10
|
+
#
|
|
11
|
+
# That is why there is no `leave.approve_own` / `leave.approve_team` /
|
|
12
|
+
# `leave.approve_all` triple: one key with three possible scopes says the
|
|
13
|
+
# same thing without three times the surface to get wrong.
|
|
14
|
+
#
|
|
15
|
+
# Keys are DATA — a role grants them from the database. They are declared
|
|
16
|
+
# here so that a typo in a controller is a boot-time failure rather than a
|
|
17
|
+
# permission that silently never matches.
|
|
18
|
+
module Permissions
|
|
19
|
+
SCOPES = %i[self team all].freeze
|
|
20
|
+
|
|
21
|
+
# Ordered weakest to strongest. `all` satisfies a `team` requirement,
|
|
22
|
+
# `team` satisfies `self`; never the other way round.
|
|
23
|
+
SCOPE_RANK = { self: 0, team: 1, all: 2 }.freeze
|
|
24
|
+
|
|
25
|
+
# key => [ group, description ]. The group is what the roles screen
|
|
26
|
+
# renders as a heading; the description is the sentence beside the
|
|
27
|
+
# checkbox, so it is written for the person granting it.
|
|
28
|
+
REGISTRY = {
|
|
29
|
+
"profile.view" => [ "People", "See employee profiles" ],
|
|
30
|
+
"profile.manage" => [ "People", "Create and edit employee profiles, onboard and offboard" ],
|
|
31
|
+
"attendance.view" => [ "Attendance", "See attendance records" ],
|
|
32
|
+
"attendance.manage" => [ "Attendance", "Correct punches and decide regularization tickets" ],
|
|
33
|
+
"leave.request" => [ "Leave", "Apply for leave and comp-off" ],
|
|
34
|
+
"leave.view" => [ "Leave", "See leave requests and balances" ],
|
|
35
|
+
"leave.approve" => [ "Leave", "Approve, reject and cancel leave and comp-off" ],
|
|
36
|
+
"leave.manage" => [ "Leave", "Adjust balances, configure leave types and holidays" ],
|
|
37
|
+
"payroll.view" => [ "Payroll", "See payroll runs and salary slips" ],
|
|
38
|
+
"payroll.manage" => [ "Payroll", "Create, compute, finalize, unlock and publish payroll" ],
|
|
39
|
+
"payroll.export" => [ "Payroll", "Download the payout register, including bank details" ],
|
|
40
|
+
"salary.view" => [ "Payroll", "See salary structures" ],
|
|
41
|
+
"salary.manage" => [ "Payroll", "Set and revise salary structures" ],
|
|
42
|
+
"appraisal.view" => [ "Growth", "See appraisals" ],
|
|
43
|
+
"appraisal.manage" => [ "Growth", "Write, share and act on appraisals and promotions" ],
|
|
44
|
+
"resignation.view" => [ "Lifecycle", "See resignations" ],
|
|
45
|
+
"resignation.manage" => [ "Lifecycle", "Accept resignations and set the last working day" ],
|
|
46
|
+
"settings.manage" => [ "Administration", "Change company settings, offices and policy" ],
|
|
47
|
+
"audit.view" => [ "Administration", "Read the audit trail" ],
|
|
48
|
+
"audit.view_money" => [ "Administration", "Read audit rows about pay, appraisals and promotions" ],
|
|
49
|
+
"role.manage" => [ "Administration", "Create roles and grant permissions" ]
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
KEYS = REGISTRY.keys.freeze
|
|
53
|
+
|
|
54
|
+
def self.valid?(key) = REGISTRY.key?(key.to_s)
|
|
55
|
+
|
|
56
|
+
# Raises rather than returning false: an unknown key in a controller is a
|
|
57
|
+
# typo that would otherwise read as "nobody may do this", which fails in
|
|
58
|
+
# the safe direction but silently, and is then very hard to find.
|
|
59
|
+
def self.validate!(key)
|
|
60
|
+
return key.to_s if valid?(key)
|
|
61
|
+
|
|
62
|
+
raise ArgumentError, "Unknown HrLite permission #{key.inspect}. " \
|
|
63
|
+
"Declare it in HrLite::Permissions::REGISTRY first."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.group(key) = REGISTRY.fetch(key.to_s).first
|
|
67
|
+
def self.description(key) = REGISTRY.fetch(key.to_s).last
|
|
68
|
+
|
|
69
|
+
def self.grouped
|
|
70
|
+
REGISTRY.group_by { |_key, (group, _)| group }
|
|
71
|
+
.transform_values { |pairs| pairs.map(&:first) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Does `held` satisfy a requirement for `needed`?
|
|
75
|
+
def self.scope_covers?(held, needed)
|
|
76
|
+
SCOPE_RANK.fetch(held.to_sym) >= SCOPE_RANK.fetch(needed.to_sym)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
module HrLite
|
|
2
|
+
# The six roles an install starts with. They are a STARTING POINT, not a
|
|
3
|
+
# ladder in code: an install is expected to edit the grants, and `roles:seed`
|
|
4
|
+
# never overwrites a role that already exists.
|
|
5
|
+
#
|
|
6
|
+
# Read the scopes as the interesting part. Manager and HR hold the same
|
|
7
|
+
# leave.approve key — the difference between them is `team` and `all`, which
|
|
8
|
+
# is the whole reason scope lives on the grant.
|
|
9
|
+
module RoleSeeds
|
|
10
|
+
# A method, not a constant: the keys are Role constants, and this file is
|
|
11
|
+
# required while the gem loads, long before Active Record models exist.
|
|
12
|
+
def self.definitions
|
|
13
|
+
{
|
|
14
|
+
Role::EMPLOYEE => {
|
|
15
|
+
description: "Self-service only: own attendance, leave, documents and payslips.",
|
|
16
|
+
grants: {
|
|
17
|
+
"leave.request" => "self", "leave.view" => "self",
|
|
18
|
+
"attendance.view" => "self", "payroll.view" => "self",
|
|
19
|
+
"profile.view" => "self", "appraisal.view" => "self",
|
|
20
|
+
"resignation.view" => "self"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
Role::MANAGER => {
|
|
24
|
+
description: "Everything an employee has, plus their reports' attendance and leave.",
|
|
25
|
+
grants: {
|
|
26
|
+
"leave.request" => "self", "leave.view" => "team", "leave.approve" => "team",
|
|
27
|
+
"attendance.view" => "team", "attendance.manage" => "team",
|
|
28
|
+
"payroll.view" => "self", "profile.view" => "team",
|
|
29
|
+
"appraisal.view" => "self", "resignation.view" => "self"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
Role::HR => {
|
|
33
|
+
description: "Day-to-day operations for everyone: attendance, leave, holidays, tickets.",
|
|
34
|
+
grants: {
|
|
35
|
+
"leave.request" => "self", "leave.view" => "all", "leave.approve" => "all",
|
|
36
|
+
"leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
|
|
37
|
+
"profile.view" => "all", "payroll.view" => "self",
|
|
38
|
+
"appraisal.view" => "self", "resignation.view" => "all"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
Role::FINANCE => {
|
|
42
|
+
description: "Payroll and pay data. No authority over people or policy.",
|
|
43
|
+
grants: {
|
|
44
|
+
"leave.request" => "self", "leave.view" => "self",
|
|
45
|
+
"attendance.view" => "all", "profile.view" => "all",
|
|
46
|
+
"payroll.view" => "all", "payroll.manage" => "all", "payroll.export" => "all",
|
|
47
|
+
"salary.view" => "all", "salary.manage" => "all",
|
|
48
|
+
"audit.view" => "all", "audit.view_money" => "all"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
Role::LEADERSHIP => {
|
|
52
|
+
description: "People and policy for everyone — deliberately NOT pay.",
|
|
53
|
+
grants: {
|
|
54
|
+
"leave.request" => "self", "leave.view" => "all", "leave.approve" => "all",
|
|
55
|
+
"leave.manage" => "all", "attendance.view" => "all", "attendance.manage" => "all",
|
|
56
|
+
"profile.view" => "all", "profile.manage" => "all",
|
|
57
|
+
"resignation.view" => "all", "resignation.manage" => "all",
|
|
58
|
+
"settings.manage" => "all", "audit.view" => "all", "payroll.view" => "self"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
Role::SUPER_ADMIN => {
|
|
62
|
+
description: "Everything, including pay, appraisals and who holds which role.",
|
|
63
|
+
grants: Permissions::KEYS.to_h { |key| [ key, "all" ] }
|
|
64
|
+
}
|
|
65
|
+
}.freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Creates any role that does not exist yet and leaves every existing one
|
|
69
|
+
# exactly as the install has tuned it — the same contract as the leave-type
|
|
70
|
+
# seed. Returns the names it created.
|
|
71
|
+
def self.call
|
|
72
|
+
definitions.filter_map do |name, definition|
|
|
73
|
+
next if Role.exists?(name: name)
|
|
74
|
+
|
|
75
|
+
role = Role.create!(name: name, description: definition[:description], system: true)
|
|
76
|
+
role.replace_grants!(definition[:grants])
|
|
77
|
+
name
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -3,9 +3,13 @@ module HrLite
|
|
|
3
3
|
# is a one-hash edit that gets code review and a spec diff, never an
|
|
4
4
|
# inline-constant hunt.
|
|
5
5
|
#
|
|
6
|
-
# VERIFY WITH A CA before the first run of any new financial year
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# VERIFY WITH A CA before the first run of any new financial year. Adding
|
|
7
|
+
# a year is one new dated entry in CARDS; nothing else changes.
|
|
8
|
+
#
|
|
9
|
+
# When no card exists for a run's financial year the lookup falls back to
|
|
10
|
+
# the newest one it has — payroll cannot simply stop every April — but
|
|
11
|
+
# `warning_for` says so on the run, and `PayrollRunProcessor` puts that
|
|
12
|
+
# sentence in front of every other warning until a card is added.
|
|
9
13
|
module StatutoryRateCard
|
|
10
14
|
def self.r(value) = BigDecimal(value.to_s)
|
|
11
15
|
private_class_method :r
|
|
@@ -58,8 +62,44 @@ module HrLite
|
|
|
58
62
|
}.freeze
|
|
59
63
|
|
|
60
64
|
def self.for(period_month)
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
CARDS[effective_date_for(period_month)]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Which card a run will actually use. A month older than every card
|
|
69
|
+
# borrows the earliest one — see `predates_cards?`, which says so.
|
|
70
|
+
def self.effective_date_for(period_month)
|
|
71
|
+
CARDS.keys.sort.reverse.find { |date| date <= period_month } || CARDS.keys.min
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The card in force is from an EARLIER financial year than the run. The
|
|
75
|
+
# lookup still returns figures — it has to, or payroll would stop dead
|
|
76
|
+
# every April — so the run carries the warning instead.
|
|
77
|
+
def self.stale_for?(period_month)
|
|
78
|
+
FinancialYear.before?(effective_date_for(period_month), period_month)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The run is older than every card we ship, so it is being computed on
|
|
82
|
+
# rates that had not been announced yet.
|
|
83
|
+
def self.predates_cards?(period_month)
|
|
84
|
+
period_month < CARDS.keys.min
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# One sentence naming both the run's FY and the card's, or nil when they
|
|
88
|
+
# match. Rendered verbatim in the run's warnings list.
|
|
89
|
+
def self.warning_for(period_month)
|
|
90
|
+
card_fy = FinancialYear.label(effective_date_for(period_month))
|
|
91
|
+
run_fy = FinancialYear.label(period_month)
|
|
92
|
+
|
|
93
|
+
if predates_cards?(period_month)
|
|
94
|
+
"Payroll for FY #{run_fy} is being computed on the FY #{card_fy} " \
|
|
95
|
+
"statutory card — no card ships for a year that early. PF, ESI, PT " \
|
|
96
|
+
"and TDS on this run are not the rates that applied. Verify with your CA."
|
|
97
|
+
elsif stale_for?(period_month)
|
|
98
|
+
"Payroll for FY #{run_fy} is being computed on the FY #{card_fy} " \
|
|
99
|
+
"statutory card — no card ships for FY #{run_fy} yet. PF, ESI, PT and " \
|
|
100
|
+
"TDS on this run use last year's rates. Add a CA-verified card for " \
|
|
101
|
+
"FY #{run_fy} before publishing."
|
|
102
|
+
end
|
|
63
103
|
end
|
|
64
104
|
end
|
|
65
105
|
end
|
data/lib/hr_lite/version.rb
CHANGED