current_scope 0.2.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +577 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +225 -0
  6. data/app/assets/stylesheets/current_scope/application.css +707 -0
  7. data/app/controllers/current_scope/application_controller.rb +54 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +104 -0
  10. data/app/controllers/current_scope/roles_controller.rb +135 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +159 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +57 -0
  13. data/app/helpers/current_scope/application_helper.rb +72 -0
  14. data/app/models/current_scope/application_record.rb +5 -0
  15. data/app/models/current_scope/current.rb +50 -0
  16. data/app/models/current_scope/event.rb +117 -0
  17. data/app/models/current_scope/role.rb +55 -0
  18. data/app/models/current_scope/role_assignment.rb +21 -0
  19. data/app/models/current_scope/role_permission.rb +9 -0
  20. data/app/models/current_scope/scoped_role_assignment.rb +14 -0
  21. data/app/views/current_scope/events/index.html.erb +41 -0
  22. data/app/views/current_scope/roles/edit.html.erb +86 -0
  23. data/app/views/current_scope/roles/index.html.erb +41 -0
  24. data/app/views/current_scope/roles/members.html.erb +96 -0
  25. data/app/views/current_scope/roles/new.html.erb +19 -0
  26. data/app/views/current_scope/scoped_role_assignments/new.html.erb +135 -0
  27. data/app/views/current_scope/subjects/index.html.erb +100 -0
  28. data/app/views/layouts/current_scope/application.html.erb +53 -0
  29. data/config/routes.rb +14 -0
  30. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  31. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  32. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  33. data/lib/current_scope/configuration.rb +181 -0
  34. data/lib/current_scope/context.rb +36 -0
  35. data/lib/current_scope/engine.rb +14 -0
  36. data/lib/current_scope/gating_tripwire.rb +53 -0
  37. data/lib/current_scope/guard.rb +97 -0
  38. data/lib/current_scope/mutation_guard.rb +55 -0
  39. data/lib/current_scope/permission_catalog.rb +33 -0
  40. data/lib/current_scope/permission_grid.rb +90 -0
  41. data/lib/current_scope/permissions.rb +57 -0
  42. data/lib/current_scope/resolver.rb +171 -0
  43. data/lib/current_scope/scopeable.rb +38 -0
  44. data/lib/current_scope/test_helpers.rb +53 -0
  45. data/lib/current_scope/version.rb +3 -0
  46. data/lib/current_scope.rb +168 -0
  47. data/lib/generators/current_scope/install/install_generator.rb +33 -0
  48. data/lib/generators/current_scope/install/templates/initializer.rb +77 -0
  49. data/lib/tasks/current_scope_tasks.rake +15 -0
  50. metadata +113 -0
@@ -0,0 +1,225 @@
1
+ // The engine's only JavaScript, and pure progressive enhancement.
2
+ //
3
+ // CSP-safe by construction: it ships as a served asset under script-src 'self'
4
+ // (never an inline onchange= handler, which a baseline CSP blocks). One
5
+ // delegated change listener auto-submits the scoped-role cascade form when a
6
+ // marked control changes — pick a resource type, or type a search — so the next
7
+ // step renders without a manual click. Every such control still has a visible
8
+ // submit button, so the cascade works with this script disabled, and with no
9
+ // Turbo at all (the submit is a plain full-page GET).
10
+ document.addEventListener("change", function (event) {
11
+ var el = event.target;
12
+ if (!el || typeof el.matches !== "function") return;
13
+ if (!el.matches("[data-current-scope-autosubmit]")) return;
14
+
15
+ var form = el.form || el.closest("form");
16
+ if (form) form.requestSubmit();
17
+ });
18
+
19
+ // CSP-safe confirmation for destructive submits. `data-turbo-confirm` only fires
20
+ // when the host loads Turbo; this engine can't assume that, so a form carrying
21
+ // data-cs-confirm gets a native window.confirm regardless. Runs in the capture
22
+ // phase so it can veto before any other submit handler acts.
23
+ document.addEventListener("submit", function (event) {
24
+ var form = event.target;
25
+ if (!form || typeof form.matches !== "function") return;
26
+ if (!form.matches("[data-cs-confirm]")) return;
27
+ if (!window.confirm(form.getAttribute("data-cs-confirm"))) event.preventDefault();
28
+ }, true);
29
+
30
+ // Light/dark theme toggle. Progressive enhancement: the server already renders
31
+ // the chosen theme from the current_scope_theme cookie (so there's no flash), and defaults
32
+ // to the OS preference when no cookie is set. This just flips the choice live
33
+ // and persists it. CSP-safe (served asset, no inline handler).
34
+ document.addEventListener("click", function (event) {
35
+ var btn = event.target.closest && event.target.closest("[data-cs-theme-toggle]");
36
+ if (!btn) return;
37
+
38
+ var root = document.documentElement;
39
+ var current = root.getAttribute("data-cs-theme");
40
+ var effectiveDark = current
41
+ ? current === "dark"
42
+ : window.matchMedia("(prefers-color-scheme: dark)").matches;
43
+ var next = effectiveDark ? "light" : "dark";
44
+
45
+ root.setAttribute("data-cs-theme", next);
46
+ // Namespaced cookie name so it can't collide with a host cookie; Secure on
47
+ // https so a UI preference isn't sent in the clear.
48
+ var secure = window.location.protocol === "https:" ? ";secure" : "";
49
+ document.cookie = "current_scope_theme=" + next + ";path=/;max-age=31536000;samesite=lax" + secure;
50
+ btn.setAttribute("aria-pressed", String(next === "dark"));
51
+ });
52
+
53
+ // Sync the toggle's aria-pressed to the theme actually rendered. The server can
54
+ // only set it from the cookie; with no cookie + OS dark the page is dark but the
55
+ // server rendered aria-pressed="false". Correct it once we can read matchMedia.
56
+ document.addEventListener("DOMContentLoaded", function () {
57
+ var btn = document.querySelector("[data-cs-theme-toggle]");
58
+ if (!btn) return;
59
+ var current = document.documentElement.getAttribute("data-cs-theme");
60
+ var effectiveDark = current
61
+ ? current === "dark"
62
+ : window.matchMedia("(prefers-color-scheme: dark)").matches;
63
+ btn.setAttribute("aria-pressed", String(effectiveDark));
64
+ });
65
+
66
+ // Permission grid: a per-row "enable all" master checkbox toggles every action in its
67
+ // controller row, and stays in sync (checked / indeterminate / unchecked) as individual
68
+ // actions change. Progressive enhancement — with JS off, each action checkbox still works.
69
+ (function () {
70
+ // Matches both channels: raw action checkboxes (role[permission_keys][]) and
71
+ // grouped CRUD checkboxes (role[permission_groups][]).
72
+ var ACTION = 'input[type="checkbox"][name^="role[permission"]';
73
+
74
+ function actionsIn(row) { return row.querySelectorAll(ACTION); }
75
+
76
+ // A partial group checkbox ships hidden [data-cs-preserve] inputs that keep its
77
+ // existing keys across a no-op save. Once the user (or the row master) drives
78
+ // the checkbox, the checkbox alone governs the group: clear the indeterminate
79
+ // hint and disable the preserve inputs so they don't force the old subset back.
80
+ function releasePartial(box) {
81
+ box.indeterminate = false;
82
+ var cell = box.closest("td");
83
+ if (!cell) return;
84
+ cell.querySelectorAll("[data-cs-preserve]").forEach(function (h) { h.disabled = true; });
85
+ }
86
+
87
+ function syncMaster(row) {
88
+ var master = row.querySelector("[data-cs-row-all]");
89
+ if (!master) return;
90
+ var boxes = actionsIn(row), checked = 0;
91
+ boxes.forEach(function (b) { if (b.checked) checked++; });
92
+ master.checked = boxes.length > 0 && checked === boxes.length;
93
+ master.indeterminate = checked > 0 && checked < boxes.length;
94
+ }
95
+
96
+ document.addEventListener("change", function (event) {
97
+ var el = event.target;
98
+ if (!el || typeof el.matches !== "function") return;
99
+
100
+ if (el.matches("[data-cs-row-all]")) {
101
+ var row = el.closest("tr");
102
+ if (row) actionsIn(row).forEach(function (b) {
103
+ b.checked = el.checked;
104
+ releasePartial(b); // keep displayed + submitted state consistent with the master
105
+ });
106
+ return;
107
+ }
108
+ if (el.matches(ACTION)) {
109
+ releasePartial(el);
110
+ var r = el.closest("tr");
111
+ if (r) syncMaster(r);
112
+ }
113
+ });
114
+
115
+ document.addEventListener("DOMContentLoaded", function () {
116
+ document.querySelectorAll("[data-cs-row-all]").forEach(function (master) {
117
+ var row = master.closest("tr");
118
+ if (row) syncMaster(row);
119
+ });
120
+ // A grouped CRUD checkbox that's checked but only partially granted
121
+ // (e.g. read = index but not show) reads as indeterminate.
122
+ document.querySelectorAll('[data-cs-partial="true"]').forEach(function (cb) {
123
+ cb.indeterminate = true;
124
+ });
125
+ });
126
+ })();
127
+
128
+ // Subjects page: client-side filter, multi-select, and a bulk "grant scoped role
129
+ // to selected" action. Framework-free (no Stimulus dependency) so it works in
130
+ // any host; pure progressive enhancement — single-subject assignment still works
131
+ // with JS off via each row's "+ scoped role" link.
132
+ (function () {
133
+ function rows() {
134
+ var list = document.querySelector("[data-cs-filter-list]");
135
+ return list ? Array.prototype.slice.call(list.querySelectorAll("[data-cs-row]")) : [];
136
+ }
137
+ function visibleRows() { return rows().filter(function (r) { return !r.hidden; }); }
138
+ function selectOf(row) { return row.querySelector("[data-cs-select]"); }
139
+ // Scan ALL rows, not just visible ones: a subject checked before the operator
140
+ // typed a filter must stay in the bulk selection (select-all still works off
141
+ // visibleRows). Otherwise filtering would silently drop checked subjects.
142
+ function selectedRows() {
143
+ return rows().filter(function (r) { var cb = selectOf(r); return cb && cb.checked; });
144
+ }
145
+
146
+ function syncBulk() {
147
+ var bar = document.querySelector("[data-cs-bulk]");
148
+ if (bar) {
149
+ var n = selectedRows().length;
150
+ bar.hidden = n === 0;
151
+ var count = bar.querySelector("[data-cs-bulk-count]");
152
+ if (count) count.textContent = String(n);
153
+ }
154
+ var all = document.querySelector("[data-cs-select-all]");
155
+ if (all) {
156
+ var vis = visibleRows();
157
+ var checked = vis.filter(function (r) { var cb = selectOf(r); return cb && cb.checked; });
158
+ all.checked = vis.length > 0 && checked.length === vis.length;
159
+ all.indeterminate = checked.length > 0 && checked.length < vis.length;
160
+ }
161
+ }
162
+
163
+ document.addEventListener("input", function (event) {
164
+ if (!event.target.matches || !event.target.matches("[data-cs-filter]")) return;
165
+ var needle = event.target.value.trim().toLowerCase();
166
+ var anyVisible = false;
167
+ rows().forEach(function (row) {
168
+ // Prefer the row's explicit filter text (subject + roles + records); fall
169
+ // back to textContent only if a row didn't provide one.
170
+ var haystack = (row.getAttribute("data-cs-filter-text") || row.textContent).toLowerCase();
171
+ var match = !needle || haystack.indexOf(needle) !== -1;
172
+ // Keep a checked (selected) row visible even when it doesn't match, so a
173
+ // subject can never sit hidden-but-selected inside a bulk action — what
174
+ // you see stays what you'll act on. selectedRows() scans all rows.
175
+ var cb = selectOf(row);
176
+ row.hidden = !match && !(cb && cb.checked);
177
+ if (!row.hidden) anyVisible = true;
178
+ });
179
+ var empty = document.querySelector("[data-cs-filter-empty]");
180
+ if (empty) empty.hidden = anyVisible || rows().length === 0;
181
+ syncBulk();
182
+ });
183
+
184
+ document.addEventListener("change", function (event) {
185
+ if (event.target.matches && event.target.matches("[data-cs-select-all]")) {
186
+ visibleRows().forEach(function (r) { var cb = selectOf(r); if (cb) cb.checked = event.target.checked; });
187
+ syncBulk();
188
+ } else if (event.target.matches && event.target.matches("[data-cs-select]")) {
189
+ syncBulk();
190
+ }
191
+ });
192
+
193
+ document.addEventListener("click", function (event) {
194
+ if (event.target.closest && event.target.closest("[data-cs-bulk-clear]")) {
195
+ rows().forEach(function (r) { var cb = selectOf(r); if (cb) cb.checked = false; });
196
+ syncBulk();
197
+ return;
198
+ }
199
+ var go = event.target.closest && event.target.closest("[data-cs-bulk-scoped]");
200
+ if (!go) return;
201
+ event.preventDefault();
202
+ var gids = selectedRows().map(function (r) { return selectOf(r).value; });
203
+ if (!gids.length) return;
204
+ var base = go.getAttribute("data-cs-bulk-url");
205
+ var query = gids.map(function (g) { return "subject_gids[]=" + encodeURIComponent(g); }).join("&");
206
+ window.location = base + (base.indexOf("?") === -1 ? "?" : "&") + query;
207
+ });
208
+
209
+ // Bulk org-wide role: inject the checked subjects into the POST form on submit.
210
+ document.addEventListener("submit", function (event) {
211
+ var form = event.target.closest && event.target.closest("[data-cs-bulk-org]");
212
+ if (!form) return;
213
+ var gids = selectedRows().map(function (r) { return selectOf(r).value; });
214
+ if (!gids.length) { event.preventDefault(); return; }
215
+ form.querySelectorAll("[data-cs-injected]").forEach(function (n) { n.remove(); });
216
+ gids.forEach(function (g) {
217
+ var input = document.createElement("input");
218
+ input.type = "hidden";
219
+ input.name = "subject_gids[]";
220
+ input.value = g;
221
+ input.setAttribute("data-cs-injected", "");
222
+ form.appendChild(input);
223
+ });
224
+ });
225
+ })();