current_scope 0.2.0 → 0.3.1

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 (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +317 -18
  3. data/app/assets/javascripts/current_scope/application.js +4 -0
  4. data/app/assets/stylesheets/current_scope/application.css +101 -0
  5. data/app/controllers/current_scope/application_controller.rb +39 -1
  6. data/app/controllers/current_scope/role_assignments_controller.rb +110 -25
  7. data/app/controllers/current_scope/roles_controller.rb +130 -32
  8. data/app/helpers/current_scope/application_helper.rb +168 -12
  9. data/app/models/current_scope/current.rb +24 -0
  10. data/app/models/current_scope/event.rb +10 -6
  11. data/app/models/current_scope/role.rb +61 -10
  12. data/app/views/current_scope/roles/edit.html.erb +92 -4
  13. data/app/views/current_scope/roles/index.html.erb +16 -2
  14. data/app/views/current_scope/roles/members.html.erb +3 -3
  15. data/app/views/current_scope/roles/new.html.erb +1 -1
  16. data/app/views/current_scope/scoped_role_assignments/new.html.erb +19 -10
  17. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  18. data/app/views/current_scope/subjects/index.html.erb +21 -7
  19. data/app/views/layouts/current_scope/application.html.erb +4 -1
  20. data/config/routes.rb +3 -4
  21. data/lib/current_scope/configuration.rb +411 -18
  22. data/lib/current_scope/engine.rb +7 -0
  23. data/lib/current_scope/gating_reflection.rb +62 -0
  24. data/lib/current_scope/gating_tripwire.rb +36 -5
  25. data/lib/current_scope/guard.rb +410 -10
  26. data/lib/current_scope/mutation_guard.rb +30 -5
  27. data/lib/current_scope/permission_catalog.rb +116 -3
  28. data/lib/current_scope/permission_grid.rb +34 -4
  29. data/lib/current_scope/permissions.rb +45 -8
  30. data/lib/current_scope/resolver.rb +317 -13
  31. data/lib/current_scope/version.rb +1 -1
  32. data/lib/current_scope.rb +113 -5
  33. data/lib/generators/current_scope/install/install_generator.rb +64 -0
  34. data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
  35. data/lib/tasks/current_scope_tasks.rake +153 -0
  36. metadata +6 -2
@@ -8,13 +8,17 @@ module CurrentScope
8
8
  has_many :scoped_role_assignments, dependent: :destroy
9
9
 
10
10
  validates :name, presence: true, uniqueness: true
11
+ validate :permission_keys_in_catalog
11
12
 
12
13
  after_save :persist_permission_keys
13
14
 
14
- # The grid diff computed by the last save: { added: [...], removed: [...] }.
15
- # Empty arrays on a no-op save; nil when no grid was staged (a programmatic
16
- # save that never set permission_keys). Controllers read this to record the
17
- # change the model itself records nothing (seeds must stay silent).
15
+ # The grid diff computed by the last save:
16
+ # { added: [...], removed: [...], rejected: [...] }. Empty arrays on a no-op
17
+ # save; nil when no grid was staged (a programmatic save that never set
18
+ # permission_keys). `added`/`removed` describe what actually persisted;
19
+ # `rejected` names keys the scrub path threw away, so a caller that opted
20
+ # into silence can still log it. Controllers read this to record the change
21
+ # — the model itself records nothing (seeds must stay silent).
18
22
  attr_reader :permission_keys_change
19
23
 
20
24
  def grants?(permission_key)
@@ -25,31 +29,78 @@ module CurrentScope
25
29
  @pending_permission_keys || role_permissions.pluck(:permission_key)
26
30
  end
27
31
 
28
- # Stages a replacement permission set, dropping keys that aren't in the
29
- # catalog (stale keys from removed controllers). Persisted on save, like
30
- # any other attribute never before validations pass.
32
+ # Stages a replacement permission set. STRICT: a key that isn't in the
33
+ # route-derived catalog makes the record invalid rather than disappearing.
34
+ # This is the mass-assignment writer (update!, strong params, the role
35
+ # grid), so it is deliberately the strict one — the scrub escape hatch must
36
+ # never be reachable from form params.
31
37
  def permission_keys=(keys)
32
- @pending_permission_keys = Array(keys).uniq.select { |k| CurrentScope.catalog.include?(k) }
38
+ assign_permission_keys(keys, scrub: false)
39
+ end
40
+
41
+ # ponytail: one implementation, two intents — the setter is the strict
42
+ # front door, this is the same staging with the scrub opt-in named at the
43
+ # call site.
44
+ #
45
+ # `scrub: true` is the ONLY sanctioned silent drop, and it exists for one
46
+ # real case: a controller was removed, so a role still holds keys that no
47
+ # longer route. Cleaning those up is legitimate and shouldn't require the
48
+ # operator to name every dead key. Everything else — typos, unrouted
49
+ # programmatic grants, the never-routed break-glass permission — is a
50
+ # mistake, and a security-grant API must not swallow mistakes.
51
+ def assign_permission_keys(keys, scrub: false)
52
+ # Literal `true` only. Ruby truthiness would let the string "false" — or
53
+ # any params/config value that found its way here — silently disable the
54
+ # strict path, which is the exact hole this API exists to close. The
55
+ # escape hatch opens for a caller that means it, not for one that passed
56
+ # something along.
57
+ @scrub_permission_keys = scrub == true
58
+ # Blank entries are the grid's hidden-field padding, not typos (R2).
59
+ @pending_permission_keys = Array(keys).map(&:to_s).reject(&:blank?).uniq
33
60
  end
34
61
 
35
62
  def reload(...)
36
63
  @pending_permission_keys = nil
64
+ @scrub_permission_keys = false
37
65
  super
38
66
  end
39
67
 
40
68
  private
41
69
 
70
+ def permission_keys_in_catalog
71
+ return if @pending_permission_keys.nil? || @scrub_permission_keys
72
+
73
+ unknown = @pending_permission_keys.reject { |k| CurrentScope.catalog.include?(k) }
74
+ return if unknown.empty?
75
+
76
+ errors.add(
77
+ :permission_keys,
78
+ "not in the permission catalog: #{unknown.join(', ')} — check for typos, or use " \
79
+ "assign_permission_keys(..., scrub: true) to drop stale keys deliberately"
80
+ )
81
+ end
82
+
42
83
  def persist_permission_keys
43
84
  return if @pending_permission_keys.nil?
44
85
 
45
86
  # Capture the prior keys BEFORE delete_all so the diff survives the swap.
46
87
  previous = role_permissions.pluck(:permission_key)
47
- staged = @pending_permission_keys
48
- @permission_keys_change = { added: staged - previous, removed: previous - staged }
88
+ # Defense in depth: on the strict path validation already proved every key
89
+ # is in the catalog, so this filter is a no-op. It is what the scrub path
90
+ # relies on, and it means no future code path that skips validations
91
+ # (insert_all, update_column, a bare `save(validate: false)`) can smuggle
92
+ # an unknown key into the table.
93
+ staged = @pending_permission_keys.select { |k| CurrentScope.catalog.include?(k) }
94
+ @permission_keys_change = {
95
+ added: staged - previous,
96
+ removed: previous - staged,
97
+ rejected: @pending_permission_keys - staged
98
+ }
49
99
 
50
100
  role_permissions.delete_all
51
101
  role_permissions.insert_all(staged.map { |k| { permission_key: k } }) if staged.any?
52
102
  @pending_permission_keys = nil
103
+ @scrub_permission_keys = false
53
104
  end
54
105
  end
55
106
  end
@@ -4,7 +4,7 @@
4
4
 
5
5
  <%= form_with model: @role do |f| %>
6
6
  <% if @role.errors.any? %>
7
- <p class="cs-alert"><%= @role.errors.full_messages.to_sentence %></p>
7
+ <p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
8
8
  <% end %>
9
9
 
10
10
  <p><%= f.label :name %> <%= f.text_field :name %></p>
@@ -20,10 +20,33 @@
20
20
  </p>
21
21
 
22
22
  <h2>Permission grid</h2>
23
+ <%# The injected break-glass action's bare name (nil when break-glass is
24
+ off) — the hint below and the per-row exemption check both read it. The
25
+ CATALOG owns the parse: its split("#", -1) + blank-raise catches a
26
+ malformed "reports#" that a loose split here would silently turn into a
27
+ believable wrong action name. (#79 review, qodo) %>
28
+ <% bypass_action = CurrentScope.config.allow_sod_bypass ? CurrentScope.catalog.bypass_action : nil %>
23
29
  <p class="cs-hint">
24
30
  One row per controller, one checkbox per action group — derived from the
25
31
  app's routes, so new controllers appear here automatically. Columns are
26
32
  fixed: a blank cell means the controller doesn't route that action.
33
+ <%# The break-glass column is the one thing here that is NOT an action you
34
+ can route, so the "derived from the app's routes" line above would
35
+ otherwise make it look like a mistake. %>
36
+ <% if CurrentScope.config.allow_sod_bypass %>
37
+ Break-glass is on, so controllers with a separation-of-duties action also
38
+ show a <code><%= bypass_action %></code>
39
+ column: granting it lets that role's holder override the four-eyes veto on
40
+ their own record, where the record allows it. Every use is audited.
41
+ <% end %>
42
+ </p>
43
+ <%# Unconditional on purpose (R9): the limit of the mark must be stated even
44
+ when nothing is marked — an unmarked row is a non-answer, not a proof. %>
45
+ <p class="cs-hint cs-ungated-hint">
46
+ Rows tagged "gate not run" are controllers provably never gated — the tag
47
+ appears only when the callback chain proves it. An unmarked row is not
48
+ proof the gate runs there (a conditional skip, for example, is unprovable
49
+ and renders unmarked).
27
50
  </p>
28
51
 
29
52
  <% grid = CurrentScope::PermissionGrid.new %>
@@ -42,15 +65,58 @@
42
65
  </thead>
43
66
  <tbody>
44
67
  <% grid.controllers.each do |controller| %>
68
+ <%# A broken controller body's NameError deliberately propagates out
69
+ of the reflection (KTD-2) — but ONE broken controller must not
70
+ take down the whole role editor for every other row. The view
71
+ renders that row as explicitly uninspectable (a visible state,
72
+ never silence, never "gated"); the rake task keeps propagating,
73
+ because its output makes proof claims a partial walk can't. %>
74
+ <% ungated = begin
75
+ grid.ungated?(controller)
76
+ rescue NameError
77
+ :uninspectable
78
+ end %>
79
+ <% uninspectable = ungated == :uninspectable
80
+ ungated = ungated == true %>
81
+ <% badge_id = "cs_ungated_#{controller.parameterize(separator: '_')}" if ungated %>
45
82
  <tr>
46
83
  <th scope="row">
47
84
  <%# Per-row master: enable/disable every action in this controller at once.
48
85
  Progressive enhancement — with JS off, each cell checkbox still works. %>
49
86
  <label class="cs-row-all" title="Enable all <%= controller %> permissions">
50
87
  <input type="checkbox" data-cs-row-all
51
- aria-label="Enable all <%= controller %> permissions">
88
+ aria-label="Enable all <%= controller %> permissions"<% if ungated %>
89
+ aria-describedby="<%= badge_id %>"<% end %>>
52
90
  <span><%= controller %></span>
53
91
  </label>
92
+ <% if uninspectable %>
93
+ <%# Not a badge and not silence: the gate state is UNKNOWN here,
94
+ and rendering nothing would read as "checked, and gated"
95
+ under the hint's own rules. %>
96
+ <span class="cs-uninspectable-note" id="cs_uninspectable_<%= controller.parameterize(separator: '_') %>">
97
+ could not inspect — this controller raised while loading, so its
98
+ gate state is unknown. Fix the load error, then reload.
99
+ </span>
100
+ <% end %>
101
+ <% if ungated %>
102
+ <%# Advisory only — marking is not disabling (KTD-8/R5): nothing
103
+ here is grayed or disabled. Same remediation vocabulary as
104
+ GatingTripwire's message. The claim is scoped to ROUTED
105
+ actions because the injected break-glass cell is live even
106
+ on a marked row (KTD-9) and carries its own note below. %>
107
+ <%# Each remedy names its cause: re-including Guard on a subclass
108
+ that inherited a skip is a NO-OP (the Concern short-circuits;
109
+ the skip already removed the callback the include would add),
110
+ so the inherited-skip host — the #62 shape this badge exists
111
+ for — must be pointed at re-assertion, not at include. %>
112
+ <span class="cs-ungated-badge" id="<%= badge_id %>">
113
+ <strong>gate not run</strong> — this controller does not run the
114
+ gate; ticking its routed actions grants nothing until it does.
115
+ If it inherited a skip, re-assert
116
+ <code>before_action :current_scope_check!</code>; if it never had
117
+ the gate, include CurrentScope::Guard.
118
+ </span>
119
+ <% end %>
54
120
  </th>
55
121
  <% grid.columns.each do |column| %>
56
122
  <% cell = grid.cell(controller, column, granted) %>
@@ -60,13 +126,35 @@
60
126
  readers; the "·" marker is decorative CSS. %>
61
127
  <td class="cs-cell-blank"></td>
62
128
  <% else %>
63
- <td>
129
+ <%# column.actions covers both shapes — an ungrouped column is
130
+ [action], and a permission_grid_groups entry containing the
131
+ bypass action must not defeat the exemption. The two guards:
132
+ the INJECTED key must actually sit on this controller (a
133
+ grouped cell can be non-blank via its other actions), and
134
+ routed? excludes the collision — a REAL routed action merely
135
+ named like the bypass permission is ordinary and inert on a
136
+ marked row; only the catalog-injected key stays live. (#79) %>
137
+ <% bypass_exempt = ungated && bypass_action &&
138
+ column.actions.include?(bypass_action) &&
139
+ CurrentScope.catalog.grouped[controller]&.include?(bypass_action) &&
140
+ !CurrentScope.catalog.routed?("#{controller}##{bypass_action}") %>
141
+ <% note_id = "cs_bypass_exempt_#{controller.parameterize(separator: '_')}" if bypass_exempt %>
142
+ <td<% if bypass_exempt %> class="cs-cell-bypass"<% end %>>
64
143
  <label>
65
144
  <%= check_box_tag cell.name, cell.value, cell.checked,
66
145
  id: "perm_#{cell.value.parameterize(separator: '_')}",
67
146
  data: { cs_partial: cell.partial },
68
- "aria-label": "#{controller} #{column.label}" %>
147
+ "aria-label": "#{controller} #{column.label}",
148
+ "aria-describedby": (bypass_exempt ? "#{badge_id} #{note_id}" : badge_id) %>
69
149
  </label>
150
+ <% if bypass_exempt %>
151
+ <%# KTD-9: break-glass is decided by GATED controllers acting
152
+ on the record type, so this grant is live despite the
153
+ row's badge — the one cell exempt from its claim. The
154
+ class is also the CSS hook that keeps the real granted
155
+ wash here while sibling cells are muted. %>
156
+ <span class="cs-bypass-exempt" id="<%= note_id %>">exempt — stays live</span>
157
+ <% end %>
70
158
  <%# Partial group: preserve the exact granted actions so a no-op
71
159
  save can't promote the group to a full grant. Interacting with
72
160
  the checkbox disables these (JS) so the group control takes over. %>
@@ -30,9 +30,23 @@
30
30
  </td>
31
31
  <td>
32
32
  <%= link_to "Members", members_role_path(role), class: "cs-btn" %>
33
+ <%
34
+ org_n = role.role_assignments.size
35
+ scoped_n = role.scoped_role_assignments.size
36
+ parts = []
37
+ parts << "#{org_n} org-wide #{"holder".pluralize(org_n)}" if org_n.positive?
38
+ parts << "#{scoped_n} scoped #{"holder".pluralize(scoped_n)}" if scoped_n.positive?
39
+ delete_prompt = if parts.empty?
40
+ "Delete role #{role.name}?"
41
+ else
42
+ "Delete role #{role.name}? This also removes it from #{parts.to_sentence}."
43
+ end
44
+ %>
33
45
  <%= button_to "Delete", role_path(role), method: :delete,
34
- form: { data: { cs_confirm: "Delete role #{role.name}?" } },
35
- data: { turbo_confirm: "Delete role #{role.name}?" } %>
46
+ class: "cs-btn cs-btn-danger",
47
+ form: { data: { cs_confirm: delete_prompt } },
48
+ aria: { label: "Delete role #{role.name}" },
49
+ data: { turbo_confirm: delete_prompt } %>
36
50
  </td>
37
51
  </tr>
38
52
  <% end %>
@@ -11,7 +11,7 @@
11
11
 
12
12
  <h2>Add org-wide members</h2>
13
13
  <% if @candidates.any? %>
14
- <%= form_with url: role_assignment_path, method: :post do %>
14
+ <%= form_with url: role_assignments_path, method: :post do %>
15
15
  <%= hidden_field_tag :role_id, @role.id %>
16
16
  <p>
17
17
  <%= label_tag "subject_gids", "Subjects" %>
@@ -46,7 +46,7 @@
46
46
  <td>
47
47
  <% if subject_gid %>
48
48
  <%# Removing = clearing this subject's org-wide role (blank role_id). %>
49
- <%= form_with url: role_assignment_path, method: :post,
49
+ <%= form_with url: role_assignments_path, method: :post,
50
50
  data: { cs_confirm: "Remove #{who} from #{@role.name}?" } do %>
51
51
  <%= hidden_field_tag :subject_gid, subject_gid %>
52
52
  <%= hidden_field_tag :role_id, "" %>
@@ -54,7 +54,7 @@
54
54
  <% end %>
55
55
  <% else %>
56
56
  <%# Orphaned assignment (subject deleted): remove it by id. %>
57
- <%= button_to "Remove", remove_role_assignment_path(assignment), method: :delete,
57
+ <%= button_to "Remove", role_assignment_path(assignment), method: :delete,
58
58
  class: "cs-btn", form: { data: { cs_confirm: "Remove this orphaned org-wide assignment?" } } %>
59
59
  <span class="cs-subtle">subject unavailable</span>
60
60
  <% end %>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <%= form_with model: @role do |f| %>
4
4
  <% if @role.errors.any? %>
5
- <p class="cs-alert"><%= @role.errors.full_messages.to_sentence %></p>
5
+ <p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
6
6
  <% end %>
7
7
 
8
8
  <p><%= f.label :name %> <%= f.text_field :name %></p>
@@ -24,15 +24,15 @@
24
24
  <%= form_with url: new_scoped_role_assignment_path, method: :get, id: "cs-cascade",
25
25
  data: { turbo_frame: "cascade" }, class: "cs-picker" do %>
26
26
  <p>
27
- <label>Role</label>
27
+ <%= label_tag :role_id, "Role" %>
28
28
  <%= select_tag :role_id,
29
29
  options_from_collection_for_select(@roles, :id, :name, params[:role_id]),
30
30
  data: { current_scope_autosubmit: true } %>
31
31
  </p>
32
32
  <% if @bulk_subjects.any? %>
33
33
  <p>
34
- <label>Subjects</label>
35
- <span class="cs-hint">
34
+ <span class="cs-label" id="cs_bulk_subjects_label">Subjects</span>
35
+ <span class="cs-hint" aria-labelledby="cs_bulk_subjects_label">
36
36
  Granting to <strong><%= @bulk_subjects.size %></strong>:
37
37
  <%= @bulk_subjects.map { |s| current_scope_subject_label(s) }.to_sentence %>
38
38
  </span>
@@ -42,7 +42,7 @@
42
42
  </p>
43
43
  <% else %>
44
44
  <p>
45
- <label>Subject</label>
45
+ <%= label_tag :subject_gid, "Subject" %>
46
46
  <%= select_tag :subject_gid,
47
47
  options_for_select(@subjects.map { |s| [ current_scope_subject_label(s), s.to_gid.to_s ] },
48
48
  params[:subject_gid]),
@@ -67,7 +67,7 @@
67
67
  end
68
68
  %>
69
69
  <p>
70
- <label>Resource type</label>
70
+ <%= label_tag :resource_type, "Resource type" %>
71
71
  <%= select_tag :resource_type,
72
72
  options_for_select(type_options, @resource_type&.name),
73
73
  include_blank: "— choose a type —", form: "cs-cascade",
@@ -82,13 +82,13 @@
82
82
  <% else %>
83
83
  <% if @searchable %>
84
84
  <p>
85
- <label>Search records</label>
85
+ <%= label_tag :q, "Search records" %>
86
86
  <%= search_field_tag :q, params[:q], placeholder: "Filter by label",
87
87
  form: "cs-cascade", data: { current_scope_autosubmit: true } %>
88
88
  </p>
89
89
  <% end %>
90
90
  <p>
91
- <label>Record</label>
91
+ <%= label_tag :resource_gid, "Record" %>
92
92
  <%
93
93
  record_options = (@records || []).map { |record| [ current_scope_label(record), record.to_gid.to_s ] }
94
94
  # Keep a deep-linked record selectable even if it fell outside the
@@ -102,9 +102,18 @@
102
102
  include_blank: "— choose a record —", form: "cs-cascade",
103
103
  data: { current_scope_autosubmit: true } %>
104
104
  <% if @searchable && params[:q].present? %>
105
- <span class="cs-hint">
106
- Showing up to <%= CurrentScope::ScopedRoleAssignmentsController::DISPLAY_LIMIT %> matches refine the search to narrow.
107
- </span>
105
+ <%# Keyed off the raw SEARCH results, not record_options — a
106
+ deep-linked @resource is prepended to the options even when the
107
+ query matched nothing, and must not fake a "matches shown". %>
108
+ <% if @records.blank? %>
109
+ <span class="cs-hint cs-picker-empty">
110
+ No records match “<%= params[:q] %>” — try a different search.
111
+ </span>
112
+ <% else %>
113
+ <span class="cs-hint">
114
+ Showing up to <%= CurrentScope::ScopedRoleAssignmentsController::DISPLAY_LIMIT %> matches — refine the search to narrow.
115
+ </span>
116
+ <% end %>
108
117
  <% end %>
109
118
  </p>
110
119
  <% end %>
@@ -0,0 +1,30 @@
1
+ <%# The engine's front-door 403. Deliberately NOT rendered in the console
2
+ layout: that layout is a sidebar of links to Roles / Subjects / Events, and
3
+ showing the console chrome to someone who cannot open any of it reads as
4
+ "you're in" while every link refuses. Standalone page, engine stylesheet, no
5
+ navigation offered that isn't there. %>
6
+ <!DOCTYPE html>
7
+ <html lang="en"<%= " data-cs-theme=\"#{cookies[:current_scope_theme]}\"".html_safe if %w[light dark].include?(cookies[:current_scope_theme]) %>>
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>Access denied — CurrentScope</title>
12
+ <%= csp_meta_tag %>
13
+ <%= stylesheet_link_tag "current_scope/application", media: "all" %>
14
+ </head>
15
+ <body>
16
+ <main class="cs-card cs-denied">
17
+ <h1>This area needs a full-access role</h1>
18
+ <p>
19
+ The authorization console is where roles and permissions are granted, so it
20
+ can't be gated by a permission you could be granted — it's open only to
21
+ subjects holding a role marked <strong>full access</strong>.
22
+ </p>
23
+ <p class="cs-hint">
24
+ If you should have it, ask someone who already does to grant you a
25
+ full-access role. If nobody has one yet, it's granted from the console or a
26
+ rake task — see the CurrentScope README's bootstrap section.
27
+ </p>
28
+ </main>
29
+ </body>
30
+ </html>
@@ -14,7 +14,10 @@
14
14
  </div>
15
15
  <% if @query.present? %>
16
16
  <p class="cs-hint">Showing matches for “<%= @query %>” across all subjects. Narrow the list below per page; selection applies to the current page.</p>
17
- <% elsif @page > 1 || @has_next_page %>
17
+ <% else %>
18
+ <%# Always shown (not just on multi-page lists): the one input drives TWO
19
+ mechanisms — typing live-filters this page, Search queries every subject —
20
+ and nothing but this line says so. %>
18
21
  <p class="cs-hint">Type to filter this page, or Search to find anyone by email or name across every subject.</p>
19
22
  <% end %>
20
23
 
@@ -23,8 +26,11 @@
23
26
  <span class="cs-spacer"></span>
24
27
  <%# Org-wide role for the selection. JS injects the checked subjects as
25
28
  subject_gids[] on submit; blank role clears it for all. %>
26
- <%= form_with url: role_assignment_path, method: :post, class: "cs-bulk-org",
27
- data: { cs_bulk_org: true } do %>
29
+ <%# Setting REPLACES each subject's current org-wide role (blank clears it) —
30
+ as consequential as Remove, which confirms, so this does too. %>
31
+ <% bulk_prompt = "Change the org-wide role for the selected subjects? This replaces their current role." %>
32
+ <%= form_with url: role_assignments_path, method: :post, class: "cs-bulk-org",
33
+ data: { cs_bulk_org: true, cs_confirm: bulk_prompt, turbo_confirm: bulk_prompt } do %>
28
34
  <%= select_tag :role_id, options_from_collection_for_select(@roles, :id, :name),
29
35
  include_blank: "org role…", aria: { label: "Org-wide role for selected" } %>
30
36
  <%= submit_tag "Set for selected", name: nil, class: "cs-btn" %>
@@ -46,7 +52,11 @@
46
52
  </thead>
47
53
  <tbody>
48
54
  <% @subjects.each do |subject| %>
49
- <% key = [ subject.class.name, subject.id ] %>
55
+ <%# Key by what the polymorphic association STORES (the base_class name),
56
+ not subject.class.name — an STI subject (AdminUser < User) is saved as
57
+ "User", and keying on the subclass name would show its roles as
58
+ "— none —" while the resolver happily enforces them. %>
59
+ <% key = [ subject.class.polymorphic_name, subject.id ] %>
50
60
  <% label = current_scope_subject_label(subject) %>
51
61
  <%# Filter against a precise text set — subject label, current org role, and
52
62
  scoped roles/records — NOT the row's textContent: every row embeds a
@@ -61,12 +71,16 @@
61
71
  </td>
62
72
  <td><%= label %></td>
63
73
  <td>
64
- <%= form_with url: role_assignment_path, method: :post do %>
74
+ <% set_prompt = "Change the org-wide role for #{label}? This replaces their current role." %>
75
+ <%= form_with url: role_assignments_path, method: :post,
76
+ data: { cs_confirm: set_prompt, turbo_confirm: set_prompt } do %>
65
77
  <%= hidden_field_tag :subject_gid, subject.to_gid %>
66
78
  <%= select_tag :role_id,
67
79
  options_from_collection_for_select(@roles, :id, :name, @assignments[key]&.role_id),
68
- include_blank: "— none —" %>
69
- <%= submit_tag "Set", name: nil %>
80
+ include_blank: "— none —",
81
+ aria: { label: "Org-wide role for #{label}" } %>
82
+ <%= submit_tag "Set", name: nil,
83
+ aria: { label: "Set org-wide role for #{label}" } %>
70
84
  <% end %>
71
85
  </td>
72
86
  <td>
@@ -15,6 +15,9 @@
15
15
  <%= javascript_include_tag "current_scope/application", defer: true %>
16
16
  </head>
17
17
  <body>
18
+ <%# WCAG 2.4.1: every page repeats the sidebar + topbar before its content —
19
+ keyboard users get a bypass. Visually hidden until focused. %>
20
+ <a class="cs-skip-link" href="#cs-main-content">Skip to content</a>
18
21
  <div class="cs-shell">
19
22
  <aside class="cs-sidebar">
20
23
  <div class="cs-brand"><span class="cs-brand-mark" aria-hidden="true"></span> CurrentScope</div>
@@ -44,7 +47,7 @@
44
47
  <% if notice.present? %><p class="cs-flash cs-flash--notice" role="status"><%= notice %></p><% end %>
45
48
  <% if alert.present? %><p class="cs-flash cs-flash--alert" role="alert"><%= alert %></p><% end %>
46
49
 
47
- <main class="cs-content">
50
+ <main class="cs-content" id="cs-main-content" tabindex="-1">
48
51
  <%= yield %>
49
52
  </main>
50
53
  </div>
data/config/routes.rb CHANGED
@@ -6,9 +6,8 @@ CurrentScope::Engine.routes.draw do
6
6
  end
7
7
  resources :subjects, only: :index
8
8
  resources :events, only: :index
9
- resource :role_assignment, only: :create
10
- # Remove one org-wide assignment by id (members page: clean up an orphan whose
11
- # subject was deleted, which the subject-keyed clear on create can't target).
12
- delete "role_assignments/:id" => "role_assignments#destroy", as: :remove_role_assignment
9
+ # create is subject-keyed (no id); destroy removes one assignment by id (the
10
+ # members page's cleanup path for an orphan whose subject was deleted).
11
+ resources :role_assignments, only: [ :create, :destroy ]
13
12
  resources :scoped_role_assignments, only: [ :new, :create, :destroy ]
14
13
  end