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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +112 -1
  3. data/README.md +67 -31
  4. data/app/controllers/hr_lite/admin/attendances_controller.rb +21 -6
  5. data/app/controllers/hr_lite/admin/base_controller.rb +16 -6
  6. data/app/controllers/hr_lite/admin/comp_off_requests_controller.rb +16 -4
  7. data/app/controllers/hr_lite/admin/leadership_controller.rb +11 -8
  8. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +4 -5
  9. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +21 -5
  10. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +11 -2
  11. data/app/controllers/hr_lite/admin/regularization_requests_controller.rb +16 -4
  12. data/app/controllers/hr_lite/admin/role_assignments_controller.rb +63 -0
  13. data/app/controllers/hr_lite/admin/roles_controller.rb +73 -0
  14. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +5 -6
  15. data/app/controllers/hr_lite/admin/superadmin_controller.rb +15 -11
  16. data/app/controllers/hr_lite/application_controller.rb +39 -1
  17. data/app/helpers/hr_lite/application_helper.rb +5 -2
  18. data/app/models/hr_lite/audit_log.rb +26 -1
  19. data/app/models/hr_lite/comp_off_request.rb +3 -1
  20. data/app/models/hr_lite/leave_request.rb +27 -4
  21. data/app/models/hr_lite/payroll_run.rb +30 -4
  22. data/app/models/hr_lite/regularization_request.rb +3 -4
  23. data/app/models/hr_lite/role.rb +66 -0
  24. data/app/models/hr_lite/role_assignment.rb +18 -0
  25. data/app/models/hr_lite/role_grant.rb +15 -0
  26. data/app/models/hr_lite/salary_slip.rb +1 -2
  27. data/app/services/hr_lite/access.rb +111 -0
  28. data/app/services/hr_lite/payroll_run_processor.rb +4 -1
  29. data/app/views/hr_lite/admin/roles/_form.html.erb +47 -0
  30. data/app/views/hr_lite/admin/roles/edit.html.erb +53 -0
  31. data/app/views/hr_lite/admin/roles/index.html.erb +49 -0
  32. data/app/views/hr_lite/admin/roles/new.html.erb +3 -0
  33. data/config/routes.rb +3 -0
  34. data/db/migrate/20260817182124_add_status_constraints_and_appraisal_fk_to_hr_lite.rb +63 -0
  35. data/db/migrate/20260817183347_create_hr_lite_roles_and_grants.rb +49 -0
  36. data/db/migrate/20260817183629_seed_hr_lite_roles_from_email_tiers.rb +82 -0
  37. data/lib/generators/hr_lite/install/templates/initializer.rb +13 -7
  38. data/lib/hr_lite/configuration.rb +11 -7
  39. data/lib/hr_lite/current.rb +4 -0
  40. data/lib/hr_lite/financial_year.rb +26 -0
  41. data/lib/hr_lite/permissions.rb +79 -0
  42. data/lib/hr_lite/role_seeds.rb +81 -0
  43. data/lib/hr_lite/statutory_rate_card.rb +45 -5
  44. data/lib/hr_lite/version.rb +1 -1
  45. data/lib/hr_lite.rb +81 -11
  46. data/lib/tasks/hr_lite_tasks.rake +10 -2
  47. metadata +17 -1
@@ -0,0 +1,73 @@
1
+ module HrLite
2
+ module Admin
3
+ # Who may do what. Gated on `role.manage`, which by default only the
4
+ # money tier holds — the authority to grant yourself payroll is the
5
+ # authority to read everyone's pay, so it lives with the pay.
6
+ class RolesController < LeadershipController
7
+ skip_before_action :require_governing_access!
8
+ before_action :require_role_management!
9
+
10
+ def index
11
+ @roles = Role.alphabetical.includes(:role_grants, :role_assignments)
12
+ end
13
+
14
+ def new
15
+ @role = Role.new
16
+ end
17
+
18
+ def create
19
+ @role = Role.new(role_params)
20
+ if @role.save
21
+ @role.replace_grants!(grant_params)
22
+ redirect_to admin_roles_path, notice: "#{@role.name} created."
23
+ else
24
+ render :new, status: :unprocessable_entity
25
+ end
26
+ end
27
+
28
+ def edit
29
+ @role = Role.find(params[:id])
30
+ end
31
+
32
+ def update
33
+ @role = Role.find(params[:id])
34
+ if @role.update(role_params)
35
+ @role.replace_grants!(grant_params)
36
+ redirect_to admin_roles_path, notice: "#{@role.name} updated."
37
+ else
38
+ render :edit, status: :unprocessable_entity
39
+ end
40
+ end
41
+
42
+ def destroy
43
+ role = Role.find(params[:id])
44
+ if role.destroy
45
+ redirect_to admin_roles_path, notice: "#{role.name} deleted.", status: :see_other
46
+ else
47
+ redirect_to admin_roles_path, alert: role.errors.full_messages.to_sentence,
48
+ status: :see_other
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def require_role_management!
55
+ hr_require_permission!("role.manage", scope: :all)
56
+ end
57
+
58
+ def role_params
59
+ permitted = params.require(:role).permit(:name, :description)
60
+ # A built-in role's NAME is what the seed, the upgrade migration and
61
+ # the specs all identify it by; the model refuses a rename, and not
62
+ # sending one keeps the form from failing on an untouched field.
63
+ @role&.system? ? permitted.except(:name) : permitted
64
+ end
65
+
66
+ # The form posts the COMPLETE picture, so a permission absent from it is
67
+ # one being taken away. "none" is how the form says "not granted".
68
+ def grant_params
69
+ params.fetch(:grants, {}).permit(Permissions::KEYS).to_h
70
+ end
71
+ end
72
+ end
73
+ end
@@ -43,12 +43,11 @@ module HrLite
43
43
  )
44
44
  slip.update!(attributes)
45
45
 
46
- AuditLog.create!(
47
- actor: hr_current_user, action: "override",
48
- subject_type: slip.class.name, subject_id: slip.id,
49
- audited_changes: { "period" => run.label, "employee" => HrLite.display_name(slip.user),
50
- "lop_override" => lop_override || "cleared",
51
- "tds_override" => tds_override ? "[changed]" : "cleared" }
46
+ AuditLog.record!(
47
+ action: "override", subject: slip, actor: hr_current_user,
48
+ changes: { "period" => run.label, "employee" => HrLite.display_name(slip.user),
49
+ "lop_override" => lop_override || "cleared",
50
+ "tds_override" => tds_override ? "[changed]" : "cleared" }
52
51
  )
53
52
  redirect_to admin_salary_slip_path(slip), notice: "Slip recomputed with overrides."
54
53
  rescue ArgumentError
@@ -1,20 +1,24 @@
1
1
  module HrLite
2
2
  module Admin
3
- # Money tier: salary structures, payroll, slips, appraisals and
4
- # promotions. Only config.superadmin_emailsordinary leadership can
5
- # govern policy and people but never sees another person's pay.
6
- # The money tier stands on its own rather than layering on leadership: a
7
- # payroll operator does not have to also govern people and policy.
8
- # Inheriting the leadership check locked a configured superadmin who was
9
- # absent from leadership_emails out of payroll entirely.
3
+ # Money screens: salary structures, payroll, slips, appraisals and
4
+ # promotions. Ordinary governing authority reaches none of it — leadership
5
+ # governs policy and people but never sees another person's pay.
6
+ #
7
+ # The money gate stands on its own rather than layering on the governing
8
+ # one: a payroll operator does not have to also govern people and policy.
9
+ # Stacking the two locked a configured money-tier user who was absent from
10
+ # the leadership list out of payroll entirely.
10
11
  class SuperadminController < LeadershipController
11
- skip_before_action :require_hr_leadership!
12
- before_action :require_hr_superadmin!
12
+ skip_before_action :require_governing_access!
13
+ before_action :require_money_access!
13
14
 
14
15
  private
15
16
 
16
- def require_hr_superadmin!
17
- hr_access_denied unless hr_superadmin?
17
+ # Deliberately payroll.manage rather than a "super admin" role name:
18
+ # roles are data an install re-cuts, and gating on a NAME would make
19
+ # renaming one a silent lock-out.
20
+ def require_money_access!
21
+ hr_require_permission!("payroll.manage", scope: :all)
18
22
  end
19
23
  end
20
24
  end
@@ -10,10 +10,48 @@ module HrLite
10
10
  before_action :hr_set_current_actor
11
11
  around_action :hr_use_time_zone
12
12
 
13
- helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name
13
+ helper_method :hr_current_user, :hr_admin?, :hr_leadership?, :hr_superadmin?, :hr_display_name,
14
+ :hr_can?, :hr_reaches?, :hr_access
14
15
 
15
16
  private
16
17
 
18
+ # --- authorization -----------------------------------------------------
19
+
20
+ def hr_access
21
+ HrLite::Access.for(hr_current_user)
22
+ end
23
+
24
+ # May the signed-in person do this at all, at `scope` or wider?
25
+ def hr_can?(key, scope: :self)
26
+ hr_access.can?(key, scope: scope)
27
+ end
28
+
29
+ # May they do it to THIS person's records? The question the module could
30
+ # not ask before roles, and whose absence let any admin approve anybody's
31
+ # leave.
32
+ def hr_reaches?(key, subject_user)
33
+ hr_access.reaches?(key, subject_user)
34
+ end
35
+
36
+ # Gate a whole controller or action. `scope` is the weakest scope that can
37
+ # reach the screen at all — a manager reaching a team screen still has
38
+ # every ROW checked separately by `hr_require_reach!`.
39
+ def hr_require_permission!(key, scope: :self)
40
+ hr_access_denied unless hr_can?(key, scope: scope)
41
+ end
42
+
43
+ # Gate one record. Deliberately a 404, not a 403: whether a particular
44
+ # person exists is not something a stranger gets to learn from us.
45
+ def hr_require_reach!(key, subject_user)
46
+ raise ActiveRecord::RecordNotFound unless hr_reaches?(key, subject_user)
47
+ end
48
+
49
+ # Narrow a relation to the rows this permission reaches, so an index
50
+ # never renders a row the member action would refuse.
51
+ def hr_scope(relation, key, column: :user_id)
52
+ hr_access.scope_relation(relation, key, column: column)
53
+ end
54
+
17
55
  def hr_authenticate!
18
56
  send(HrLite.config.authenticate_method)
19
57
  end
@@ -24,9 +24,12 @@ module HrLite
24
24
  { label: "Audit", path: :admin_audit_logs_path, match: [ "/admin/audit_logs" ] }
25
25
  ].freeze
26
26
 
27
- # Money tier only config.superadmin_emails see these.
27
+ # Money screens, plus role management: granting somebody payroll is the
28
+ # same act as holding it, so it sits with the pay rather than with the
29
+ # other governing screens.
28
30
  SUPERADMIN_NAV_ITEMS = [
29
- { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] }
31
+ { label: "Payroll", path: :admin_payroll_runs_path, match: [ "/admin/payroll_runs", "/admin/salary_slips" ] },
32
+ { label: "Roles", path: :admin_roles_path, match: [ "/admin/roles" ] }
30
33
  ].freeze
31
34
 
32
35
  # Only items whose routes exist yet (the nav grows with each phase).
@@ -12,11 +12,36 @@ module HrLite
12
12
  # encrypt their amounts so the diff is already redacted, but appraisal
13
13
  # ratings and review text are plain columns — they must not reach the
14
14
  # leadership audit screen or the policy.changed email.
15
- MONEY_TIER_TYPES = %w[HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure].freeze
15
+ MONEY_TIER_TYPES = %w[
16
+ HrLite::Appraisal HrLite::DesignationChange HrLite::SalaryStructure
17
+ HrLite::PayrollRun HrLite::SalarySlip
18
+ ].freeze
16
19
 
17
20
  scope :recent, -> { order(created_at: :desc) }
18
21
  scope :outside_money_tier, -> { where.not(subject_type: MONEY_TIER_TYPES) }
19
22
 
23
+ # The five-line `create!` that four call sites had each spelled out.
24
+ #
25
+ # This RAISES, unlike the Audited concern, which swallows failures so an
26
+ # audit hiccup cannot roll back an ordinary policy edit. On the money
27
+ # path that trade goes the other way: a payroll transition nobody can
28
+ # explain afterwards should not be allowed to happen at all, so callers
29
+ # run it inside the same transaction as the write it describes.
30
+ #
31
+ # `changes` is a whitelist the caller writes out by hand. Never pass
32
+ # amounts — this table is not encrypted.
33
+ def self.record!(action:, subject:, actor: HrLite::Current.actor, changes: {})
34
+ create!(
35
+ actor: actor,
36
+ action: action,
37
+ subject_type: subject.class.name,
38
+ # A destroyed record has no id left, and the row still has to say
39
+ # what happened.
40
+ subject_id: subject.id || 0,
41
+ audited_changes: changes
42
+ )
43
+ end
44
+
20
45
  def money_tier?
21
46
  MONEY_TIER_TYPES.include?(subject_type)
22
47
  end
@@ -54,7 +54,9 @@ module HrLite
54
54
 
55
55
  Notifications.publish(
56
56
  "comp_off.approved",
57
- title: "Comp-off approved — #{credit_days.to_f} day#{'s' if credit_days > 1} credited for #{date_worked.strftime('%d %b')}",
57
+ # credit_days is 0.5 or 1 and nothing else, so there is no plural to
58
+ # reach for here.
59
+ title: "Comp-off approved — #{credit_days.to_f} day credited for #{date_worked.strftime('%d %b')}",
58
60
  body: decision_note.presence,
59
61
  path: "/comp_off_requests",
60
62
  bell_to: [ user ], email_to: [ user ]
@@ -78,10 +78,15 @@ module HrLite
78
78
 
79
79
  def cancel!(actor:)
80
80
  was_approved = approved?
81
- self.status = "cancelled"
82
- self.decided_by_id = actor.id
83
- self.decided_at = Time.current
84
- save!
81
+ transaction do
82
+ self.status = "cancelled"
83
+ self.decided_by_id = actor.id
84
+ self.decided_at = Time.current
85
+ save!
86
+ # Cancelling an APPROVED leave hands the days back to the balance, so
87
+ # it is a quota change as much as a status change.
88
+ audit!("cancelled", actor, was_approved ? "was approved" : nil)
89
+ end
85
90
 
86
91
  Notifications.publish(
87
92
  "leave.cancelled",
@@ -117,9 +122,27 @@ module HrLite
117
122
  self.decided_at = Time.current
118
123
  self.decision_note = note
119
124
  save!
125
+ audit!(new_status, actor, note)
120
126
  end
121
127
  end
122
128
 
129
+ # Approve and reject both come through `transition!`, and `with_lock` is
130
+ # already a transaction — so a failed audit row rolls the decision back
131
+ # rather than leaving one that nobody can account for. The employee's own
132
+ # `reason` is deliberately not copied here; the approver's note is.
133
+ def audit!(action, actor, note)
134
+ AuditLog.record!(
135
+ action: "leave.#{action}", subject: self, actor: actor,
136
+ changes: {
137
+ "employee" => HrLite.display_name(user),
138
+ "type" => leave_type.code,
139
+ "dates" => date_range_label,
140
+ "days" => days_count&.to_s("F"),
141
+ "note" => note.presence
142
+ }.compact
143
+ )
144
+ end
145
+
123
146
  # This request is still pending here, so balance.used excludes it:
124
147
  # approving is valid iff the request fits the remaining balance.
125
148
  def insufficient_balance_now?
@@ -39,7 +39,12 @@ module HrLite
39
39
  begin
40
40
  update!(status: "processing")
41
41
  PayrollRunProcessor.call(self)
42
- update!(status: "review", processed_at: Time.current)
42
+ transaction do
43
+ update!(status: "review", processed_at: Time.current)
44
+ audit!("payroll.computed", actor,
45
+ "slips" => salary_slips.count, "warnings" => warnings.length,
46
+ "from_status" => previous)
47
+ end
43
48
  true
44
49
  rescue => e
45
50
  # Restore what the run WAS. Hardcoding "draft" here used to demote a
@@ -54,7 +59,12 @@ module HrLite
54
59
  raise_unless %w[review]
55
60
  raise ActiveRecord::RecordInvalid.new(self), "no slips" if salary_slips.none?
56
61
 
57
- update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
62
+ transaction do
63
+ update!(status: "finalized", finalized_at: Time.current, finalized_by_id: actor.id)
64
+ # Finalizing freezes every slip in the run. Who did it, to how many
65
+ # people, is the row an investigation starts from.
66
+ audit!("payroll.finalized", actor, "slips" => salary_slips.count)
67
+ end
58
68
  Notifications.publish(
59
69
  "payroll.finalized",
60
70
  title: "Payroll #{label} finalized — #{salary_slips.count} slips, net #{Money.round2(total_net).to_s('F')}",
@@ -65,13 +75,21 @@ module HrLite
65
75
 
66
76
  def unlock!(actor:)
67
77
  raise_unless %w[finalized]
68
- update!(status: "review")
78
+ transaction do
79
+ update!(status: "review")
80
+ # Reopening frozen slips for editing is the single most sensitive
81
+ # transition in the module — it is the one that has to leave a trace.
82
+ audit!("payroll.unlocked", actor, "slips" => salary_slips.count)
83
+ end
69
84
  true
70
85
  end
71
86
 
72
87
  def publish!(actor:)
73
88
  raise_unless %w[finalized]
74
- update!(status: "published", published_at: Time.current, published_by_id: actor.id)
89
+ transaction do
90
+ update!(status: "published", published_at: Time.current, published_by_id: actor.id)
91
+ audit!("payroll.published", actor, "slips" => salary_slips.count)
92
+ end
75
93
 
76
94
  slips = salary_slips.includes(:user).to_a
77
95
  # Everyone is emailed — a final settlement matters most to the person who
@@ -106,6 +124,14 @@ module HrLite
106
124
 
107
125
  private
108
126
 
127
+ # Amounts stay out of it on purpose: audit_logs is a plaintext table and
128
+ # every slip amount in this module is encrypted. The row records WHO
129
+ # moved the run and HOW FAR, never what anyone is paid.
130
+ def audit!(action, actor, changes)
131
+ AuditLog.record!(action: action, subject: self, actor: actor,
132
+ changes: changes.merge("period" => label))
133
+ end
134
+
109
135
  def sum_slips(attribute)
110
136
  salary_slips.sum(BigDecimal(0)) { |slip| slip.public_send(attribute) || BigDecimal(0) }
111
137
  end
@@ -71,10 +71,9 @@ module HrLite
71
71
  raise InvalidMerge, record.errors.full_messages.to_sentence unless record.valid?
72
72
 
73
73
  record.save!
74
- AuditLog.create!(
75
- actor: actor, action: "regularize",
76
- subject_type: record.class.name, subject_id: record.id,
77
- audited_changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
74
+ AuditLog.record!(
75
+ action: "regularize", subject: record, actor: actor,
76
+ changes: { "date" => record.date.to_s, "ticket" => id, "note" => reason }
78
77
  )
79
78
  end
80
79
 
@@ -0,0 +1,66 @@
1
+ module HrLite
2
+ # A named bundle of grants. Roles are DATA — the seeded six are a starting
3
+ # point an install is expected to edit, not a fixed ladder in code.
4
+ class Role < ApplicationRecord
5
+ include Audited
6
+
7
+ # Seeded by name. `Employee` in particular is load-bearing: it is what a
8
+ # new hire is given so that self-service works on day one.
9
+ EMPLOYEE = "Employee".freeze
10
+ MANAGER = "Manager".freeze
11
+ HR = "HR".freeze
12
+ FINANCE = "Finance".freeze
13
+ LEADERSHIP = "Leadership".freeze
14
+ SUPER_ADMIN = "Super Admin".freeze
15
+
16
+ has_many :role_grants, dependent: :destroy
17
+ has_many :role_assignments, dependent: :destroy
18
+
19
+ validates :name, presence: true, uniqueness: { case_sensitive: false }
20
+ before_destroy :protect_system_role
21
+ validate :system_role_keeps_its_name, on: :update
22
+
23
+ scope :alphabetical, -> { order(:name) }
24
+
25
+ # Every key this role grants, mapped to its scope. The shape the access
26
+ # resolver merges.
27
+ def grant_map
28
+ role_grants.to_h { |grant| [ grant.permission_key, grant.scope ] }
29
+ end
30
+
31
+ # Replaces the whole grant set in one transaction — the roles screen posts
32
+ # the complete picture, so a permission absent from the form is a
33
+ # permission being taken away, not one left alone.
34
+ def replace_grants!(grants)
35
+ transaction do
36
+ role_grants.destroy_all
37
+ grants.each do |key, scope|
38
+ next if scope.blank? || scope.to_s == "none"
39
+
40
+ role_grants.create!(permission_key: Permissions.validate!(key), scope: scope.to_s)
41
+ end
42
+ end
43
+ reload
44
+ end
45
+
46
+ def system? = self[:system]
47
+
48
+ private
49
+
50
+ def protect_system_role
51
+ return unless system?
52
+
53
+ errors.add(:base, "#{name} is a built-in role and cannot be deleted")
54
+ throw :abort
55
+ end
56
+
57
+ # The name is the identifier the seed, the upgrade path and the specs all
58
+ # use. Permissions on a system role are free to change; what it is called
59
+ # is not.
60
+ def system_role_keeps_its_name
61
+ return unless system? && name_changed?
62
+
63
+ errors.add(:name, "cannot be changed on a built-in role")
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,18 @@
1
+ module HrLite
2
+ # Who holds which role. Assignments are audited because granting somebody
3
+ # the money tier is exactly the kind of change that has to be explainable
4
+ # afterwards.
5
+ class RoleAssignment < ApplicationRecord
6
+ include Audited
7
+
8
+ belongs_to :role
9
+ belongs_to :user, class_name: HrLite.config.user_class
10
+ belongs_to :granted_by, class_name: HrLite.config.user_class, optional: true
11
+
12
+ validates :user_id, uniqueness: { scope: :role_id }
13
+
14
+ scope :for_user, ->(user) { where(user_id: user.id) }
15
+
16
+ def hr_lite_audit_label = "#{HrLite.display_name(user)} — #{role.name}"
17
+ end
18
+ end
@@ -0,0 +1,15 @@
1
+ module HrLite
2
+ # One (role, permission, scope) triple. The permission key is validated
3
+ # against the declared registry, so a typo is a refused write rather than a
4
+ # grant that silently never matches anything.
5
+ class RoleGrant < ApplicationRecord
6
+ belongs_to :role
7
+
8
+ validates :permission_key, presence: true,
9
+ inclusion: { in: Permissions::KEYS, message: "is not a known permission" },
10
+ uniqueness: { scope: :role_id }
11
+ validates :scope, inclusion: { in: Permissions::SCOPES.map(&:to_s) }
12
+
13
+ def covers?(needed_scope) = Permissions.scope_covers?(scope, needed_scope)
14
+ end
15
+ end
@@ -40,8 +40,7 @@ module HrLite
40
40
  # Indian FY (Apr 1) to-date sums of SETTLED slips before this period —
41
41
  # feeds the TDS projector and the slip's YTD table.
42
42
  def self.fy_to_date(user, period_month)
43
- fy_start = period_month.month >= 4 ? Date.new(period_month.year, 4, 1)
44
- : Date.new(period_month.year - 1, 4, 1)
43
+ fy_start = FinancialYear.start_for(period_month)
45
44
  slips = settled.where(user_id: user.id)
46
45
  .where(period_month: fy_start...period_month)
47
46
  {
@@ -0,0 +1,111 @@
1
+ module HrLite
2
+ # The one place that answers "may this person do this, and to whose rows".
3
+ #
4
+ # Two questions, deliberately separated:
5
+ #
6
+ # can?(user, "leave.approve") — may they at all, at any scope
7
+ # scope_for(user, "leave.approve") — :self, :team, :all or nil
8
+ # reaches?(user, "leave.approve", other) — may they, for THIS person
9
+ #
10
+ # A controller that only asks the first question is the bug this class
11
+ # exists to prevent: before roles, any admin could approve anybody's leave
12
+ # because nothing ever asked whose leave it was.
13
+ class Access
14
+ # Resolution touches three tables and every screen asks several times per
15
+ # request, so it is memoized per user for the life of the request.
16
+ def self.for(user)
17
+ # An unsaved user holds nothing: roles are rows keyed by user id, and
18
+ # there is no id yet to key them by.
19
+ return new(nil, {}) if user.nil? || user.id.nil?
20
+
21
+ Current.access_cache ||= {}
22
+ Current.access_cache[user.id] ||= new(user, resolve(user))
23
+ end
24
+
25
+ # The strongest scope held for each key across all of the user's roles.
26
+ # Two roles granting the same key keep the WIDER of the two — roles add
27
+ # up, they do not narrow each other.
28
+ def self.resolve(user)
29
+ grants = RoleGrant.joins(role: :role_assignments)
30
+ .where(hr_lite_role_assignments: { user_id: user.id })
31
+ .pluck(:permission_key, :scope)
32
+
33
+ grants.each_with_object({}) do |(key, scope), map|
34
+ held = map[key]
35
+ map[key] = scope if held.nil? || Permissions.scope_covers?(scope, held)
36
+ end
37
+ end
38
+
39
+ def initialize(user, permission_map)
40
+ @user = user
41
+ @map = permission_map
42
+ end
43
+
44
+ attr_reader :user
45
+
46
+ def scope_for(key)
47
+ @map[Permissions.validate!(key)]&.to_sym
48
+ end
49
+
50
+ def can?(key, scope: :self)
51
+ held = scope_for(key)
52
+ held.present? && Permissions.scope_covers?(held, scope)
53
+ end
54
+
55
+ # Whether this permission reaches a particular person's rows. `self`
56
+ # reaches only the holder; `team` reaches their direct and indirect
57
+ # reports; `all` reaches everyone.
58
+ def reaches?(key, subject_user)
59
+ return false if user.nil? || subject_user.nil?
60
+
61
+ case scope_for(key)
62
+ when :all then true
63
+ when :team then subject_user.id == user.id || report_ids.include?(subject_user.id)
64
+ when :self then subject_user.id == user.id
65
+ else false
66
+ end
67
+ end
68
+
69
+ # User ids this person's `team` scope covers, for scoping a whole
70
+ # relation rather than checking one row at a time. `all` returns nil,
71
+ # meaning "do not filter" — a caller that treats nil as an empty list
72
+ # would show leadership nothing, so the callers use `scope_relation`.
73
+ def visible_user_ids(key)
74
+ case scope_for(key)
75
+ when :all then nil
76
+ when :team then [ user.id, *report_ids ]
77
+ when :self then [ user.id ]
78
+ else []
79
+ end
80
+ end
81
+
82
+ # Narrows a relation to the rows this permission reaches. The column is
83
+ # named because not every table calls it user_id.
84
+ def scope_relation(relation, key, column: :user_id)
85
+ ids = visible_user_ids(key)
86
+ ids.nil? ? relation : relation.where(column => ids)
87
+ end
88
+
89
+ # Everyone below this person in the reporting chain. Walked breadth-first
90
+ # with a seen set — EmployeeProfile validates the chain is acyclic, but a
91
+ # cycle written directly to the database must not spin here forever.
92
+ def report_ids
93
+ @report_ids ||= begin
94
+ by_manager = EmployeeProfile.where.not(manager_id: nil).pluck(:manager_id, :user_id)
95
+ .group_by(&:first)
96
+ .transform_values { |pairs| pairs.map(&:last) }
97
+ found = []
98
+ seen = [ user.id ].to_set
99
+ queue = by_manager[user.id]&.dup || []
100
+
101
+ while (id = queue.shift)
102
+ next unless seen.add?(id)
103
+
104
+ found << id
105
+ queue.concat(by_manager[id] || [])
106
+ end
107
+ found
108
+ end
109
+ end
110
+ end
111
+ end
@@ -12,7 +12,10 @@ module HrLite
12
12
  end
13
13
 
14
14
  def call
15
- warnings = []
15
+ # First in the list on purpose: if the statutory card is from an older
16
+ # financial year, every number below it is computed on last year's
17
+ # rates and that is the thing to read first.
18
+ warnings = Array(StatutoryRateCard.warning_for(@run.period_month))
16
19
 
17
20
  ActiveRecord::Base.transaction do
18
21
  eligible = EmployeeProfile.active_for(@run.period_month).includes(:user).to_a
@@ -0,0 +1,47 @@
1
+ <section class="hrl-card">
2
+ <%= form_with model: role, url: url, method: method, local: true do |f| %>
3
+ <%= render "hr_lite/shared/form_errors", record: role %>
4
+
5
+ <div class="hrl-field">
6
+ <%= f.label :name %>
7
+ <% if role.system? %>
8
+ <%= f.text_field :name, disabled: true %>
9
+ <span class="hrl-small hrl-muted">
10
+ Built-in roles keep their name — the upgrade path and the seed both
11
+ identify them by it. The permissions below are yours to change.
12
+ </span>
13
+ <% else %>
14
+ <%= f.text_field :name %>
15
+ <% end %>
16
+ </div>
17
+ <div class="hrl-field">
18
+ <%= f.label :description, "Description (shown on the roles list)" %>
19
+ <%= f.text_field :description %>
20
+ </div>
21
+
22
+ <% HrLite::Permissions.grouped.each do |group, keys| %>
23
+ <fieldset>
24
+ <legend><%= group %></legend>
25
+ <% keys.each do |key| %>
26
+ <% held = role.grant_map[key] || "none" %>
27
+ <div class="hrl-field">
28
+ <%= label_tag "grants_#{key.tr('.', '_')}", HrLite::Permissions.description(key) %>
29
+ <%= select_tag "grants[#{key}]",
30
+ options_for_select([
31
+ [ "Not granted", "none" ],
32
+ [ "Their own records", "self" ],
33
+ [ "Their team's records", "team" ],
34
+ [ "Everyone's records", "all" ]
35
+ ], held),
36
+ id: "grants_#{key.tr('.', '_')}" %>
37
+ </div>
38
+ <% end %>
39
+ </fieldset>
40
+ <% end %>
41
+
42
+ <div class="hrl-form-actions">
43
+ <%= f.submit "Save", class: "hrl-btn hrl-btn--primary" %>
44
+ <a class="hrl-btn" href="<%= hr_lite.admin_roles_path %>">Cancel</a>
45
+ </div>
46
+ <% end %>
47
+ </section>