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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +577 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +225 -0
- data/app/assets/stylesheets/current_scope/application.css +707 -0
- data/app/controllers/current_scope/application_controller.rb +54 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +104 -0
- data/app/controllers/current_scope/roles_controller.rb +135 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +159 -0
- data/app/controllers/current_scope/subjects_controller.rb +57 -0
- data/app/helpers/current_scope/application_helper.rb +72 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +50 -0
- data/app/models/current_scope/event.rb +117 -0
- data/app/models/current_scope/role.rb +55 -0
- data/app/models/current_scope/role_assignment.rb +21 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +14 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +86 -0
- data/app/views/current_scope/roles/index.html.erb +41 -0
- data/app/views/current_scope/roles/members.html.erb +96 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +135 -0
- data/app/views/current_scope/subjects/index.html.erb +100 -0
- data/app/views/layouts/current_scope/application.html.erb +53 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
- data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
- data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
- data/lib/current_scope/configuration.rb +181 -0
- data/lib/current_scope/context.rb +36 -0
- data/lib/current_scope/engine.rb +14 -0
- data/lib/current_scope/gating_tripwire.rb +53 -0
- data/lib/current_scope/guard.rb +97 -0
- data/lib/current_scope/mutation_guard.rb +55 -0
- data/lib/current_scope/permission_catalog.rb +33 -0
- data/lib/current_scope/permission_grid.rb +90 -0
- data/lib/current_scope/permissions.rb +57 -0
- data/lib/current_scope/resolver.rb +171 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +168 -0
- data/lib/generators/current_scope/install/install_generator.rb +33 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +77 -0
- data/lib/tasks/current_scope_tasks.rake +15 -0
- 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
|
+
})();
|