super_auth 0.4.0 → 0.7.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.
@@ -0,0 +1,91 @@
1
+ require "optparse"
2
+ require "super_auth/editor"
3
+
4
+ module SuperAuth
5
+ class Editor
6
+ # Boots the editor from the command line: connect, optionally migrate and
7
+ # seed, then serve on loopback. See exe/super_auth-editor.
8
+ module CLI
9
+ DEFAULTS = { host: "127.0.0.1", port: 4666, migrate: false, seed: false }.freeze
10
+ LOOPBACK_BINDS = %w[127.0.0.1 localhost ::1].freeze
11
+ # Host headers accepted when bound to loopback (DNS-rebinding defence).
12
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
13
+ WARNING = "WARNING: the editor has no authentication. " \
14
+ "Anyone who can reach this port can rewrite the authorization graph.".freeze
15
+
16
+ module_function
17
+
18
+ def parse(argv, out: $stdout)
19
+ options = DEFAULTS.dup
20
+ parser = OptionParser.new do |o|
21
+ o.banner = "Usage: super_auth-editor [options]"
22
+ o.on("-H", "--host HOST", "bind address (default #{DEFAULTS[:host]})") { |v| options[:host] = v }
23
+ o.on("-p", "--port PORT", Integer, "port (default #{DEFAULTS[:port]})") { |v| options[:port] = v }
24
+ o.on("--migrate", "run the super_auth Sequel migrations before starting") { options[:migrate] = true }
25
+ o.on("--seed", "replace the whole graph with the Acme Cloud sample (destructive)") { options[:seed] = true }
26
+ o.on("-v", "--version", "print the version") do
27
+ out.puts SuperAuth::VERSION
28
+ exit
29
+ end
30
+ o.on("-h", "--help", "show this help") do
31
+ out.puts o
32
+ out.puts "Reads SUPER_AUTH_DATABASE_URL (required)."
33
+ out.puts WARNING
34
+ exit
35
+ end
36
+ end
37
+ parser.parse!(argv.dup)
38
+ options
39
+ rescue OptionParser::ParseError => e
40
+ raise SuperAuth::Error, "#{e.message}\n#{parser}"
41
+ end
42
+
43
+ def run(argv, env: ENV, err: $stderr)
44
+ options = parse(argv)
45
+ url = env["SUPER_AUTH_DATABASE_URL"].to_s.strip
46
+ if url.empty?
47
+ raise SuperAuth::Error, "SUPER_AUTH_DATABASE_URL is not set. Point it at the database that holds " \
48
+ "the super_auth tables, e.g. postgres://user:password@localhost/app_development"
49
+ end
50
+
51
+ # First Sequel::Database in the process: the models bind to it.
52
+ begin
53
+ SuperAuth.db = Sequel.connect(url)
54
+ rescue Sequel::AdapterNotFound => e
55
+ raise SuperAuth::Error, "#{e.message}. Install the adapter gem for this URL (pg, mysql2 or sqlite3)."
56
+ end
57
+
58
+ SuperAuth.install_migrations if options[:migrate]
59
+ begin
60
+ SuperAuth.load
61
+ rescue Sequel::DatabaseError => e
62
+ raise SuperAuth::Error, "super_auth tables not found at #{redact(url)} (#{e.message.lines.first.to_s.strip}). " \
63
+ "Run with --migrate to create them, or run your application's migrations."
64
+ end
65
+
66
+ if options[:seed]
67
+ require "super_auth/editor/seed"
68
+ counts = SuperAuth::Editor::Seed.run!
69
+ err.puts "Seeded the Acme Cloud sample graph: #{counts.map { |k, v| "#{v} #{k}" }.join(', ')}."
70
+ end
71
+
72
+ begin
73
+ require "rackup"
74
+ rescue LoadError
75
+ raise SuperAuth::Error, "The editor needs a Rack server: gem install rackup webrick (puma also works)."
76
+ end
77
+
78
+ loopback = LOOPBACK_BINDS.include?(options[:host])
79
+ err.puts WARNING
80
+ err.puts "Bound to #{options[:host]}, which other machines can reach." unless loopback
81
+ err.puts "Editor at http://#{options[:host]}:#{options[:port]}"
82
+ app = SuperAuth::Editor.new(hosts: loopback ? LOOPBACK_HOSTS : nil)
83
+ Rackup::Server.start(app: app, Host: options[:host], Port: options[:port], environment: "deployment")
84
+ end
85
+
86
+ def redact(url)
87
+ url.sub(%r{//([^:/@]+):[^@]*@}, '//\1:***@')
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,423 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>SuperAuth · Graph Editor</title>
7
+ <style>
8
+ :root{
9
+ --bg:#0f1216; --panel:#161a20; --panel-2:#1b2028; --line:#272e38;
10
+ --ink:#e9e7e1; --ink-soft:#9aa1ac; --ink-faint:#646b76;
11
+ --brass:#d4a84b;
12
+ --c-group:#49b3a3; --c-user:#d4a84b; --c-role:#a888dc; --c-perm:#5b9bd4; --c-resource:#d67d6b;
13
+ --mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
14
+ --sans:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
15
+ }
16
+ *{box-sizing:border-box}
17
+ html,body{height:100%;margin:0}
18
+ body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;display:flex;flex-direction:column;overflow:hidden}
19
+ button{font-family:inherit;cursor:pointer}
20
+ ::selection{background:var(--brass);color:#161206}
21
+
22
+ /* ---- header ---- */
23
+ header{display:flex;align-items:center;gap:16px;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel);flex:none}
24
+ .brand{font-family:var(--mono);font-weight:700;letter-spacing:-.02em;display:flex;align-items:center;gap:9px;font-size:15px}
25
+ .brand .dot{width:12px;height:12px;border-radius:50%;background:var(--brass);box-shadow:0 0 0 3px rgba(212,168,75,.18)}
26
+ .tools{display:flex;gap:8px;margin-left:6px}
27
+ .tbtn{background:transparent;border:1px solid var(--line);color:var(--ink-soft);border-radius:7px;padding:7px 12px;font-size:12.5px;font-family:var(--mono);transition:all .15s}
28
+ .tbtn:hover{border-color:var(--brass);color:var(--ink)}
29
+ .tbtn.on{background:var(--brass);border-color:var(--brass);color:#161206;font-weight:600}
30
+ .spacer{flex:1}
31
+ .hint{color:var(--ink-faint);font-size:12px;font-family:var(--mono)}
32
+
33
+ /* ---- compiled strip ---- */
34
+ #compiled{display:flex;align-items:center;gap:12px;padding:6px 16px;border-bottom:1px solid var(--line);background:var(--panel-2);flex:none;font-family:var(--mono);font-size:12px;color:var(--ink-soft)}
35
+ #compiled b{color:var(--ink)}
36
+ #compiled .tbtn{padding:4px 10px}
37
+
38
+ /* ---- inspector strip ---- */
39
+ #inspector{display:none;align-items:center;gap:12px;padding:8px 16px;border-bottom:1px solid var(--line);background:var(--panel-2);flex:none;min-height:44px;overflow-x:auto;white-space:nowrap}
40
+ #inspector.show{display:flex}
41
+ .sel-chip{display:inline-flex;align-items:center;gap:8px;font-family:var(--mono);font-size:12.5px;padding:5px 10px;border-radius:7px;border:1px solid var(--line);background:var(--panel)}
42
+ .sel-chip .swatch{width:9px;height:9px;border-radius:2px}
43
+ .rel-counts{display:flex;gap:10px;font-family:var(--mono);font-size:11.5px;color:var(--ink-soft)}
44
+ .rel-counts b{color:var(--ink)}
45
+ .conns{display:flex;gap:6px;align-items:center;flex-wrap:nowrap}
46
+ .conns .lbl{font-size:11px;color:var(--ink-faint);font-family:var(--mono);text-transform:uppercase;letter-spacing:.08em}
47
+ .edge-chip{display:inline-flex;align-items:center;gap:6px;font-family:var(--mono);font-size:11.5px;padding:3px 6px 3px 9px;border-radius:6px;border:1px solid var(--line);background:var(--panel);color:var(--ink-soft)}
48
+ .edge-chip .x{color:var(--ink-faint);border:none;background:none;padding:0 2px;font-size:13px;line-height:1}
49
+ .edge-chip .x:hover{color:var(--c-resource)}
50
+
51
+ /* ---- layout ---- */
52
+ main{flex:1;display:flex;flex-direction:column;min-height:0;gap:10px;padding:10px}
53
+ .region{display:grid;gap:10px;min-height:0}
54
+ .region.top{flex:1.15;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;
55
+ grid-template-areas:"group role" "user perm"}
56
+ .region.bottom{flex:.85;grid-template-columns:1fr 1fr;grid-template-rows:1fr;
57
+ grid-template-areas:"resource user2"}
58
+
59
+ /* ---- box ---- */
60
+ .box{background:var(--panel);border:1px solid var(--line);border-radius:10px;display:flex;flex-direction:column;min-height:0;overflow:hidden}
61
+ .box[data-area=group]{grid-area:group;--accent:var(--c-group)}
62
+ .box[data-area=role]{grid-area:role;--accent:var(--c-role)}
63
+ .box[data-area=user]{grid-area:user;--accent:var(--c-user)}
64
+ .box[data-area=permission]{grid-area:perm;--accent:var(--c-perm)}
65
+ .box[data-area=resource]{grid-area:resource;--accent:var(--c-resource)}
66
+ .box[data-area=user2]{grid-area:user2;--accent:var(--c-user)}
67
+ .box-head{display:flex;align-items:center;gap:9px;padding:9px 12px;border-bottom:1px solid var(--line);flex:none}
68
+ .box-head .swatch{width:10px;height:10px;border-radius:3px;background:var(--accent);flex:none;box-shadow:0 0 10px -2px var(--accent)}
69
+ .box-head .title{font-family:var(--mono);font-weight:600;font-size:13px;letter-spacing:.02em}
70
+ .box-head .count{font-family:var(--mono);font-size:11px;color:var(--ink-faint);background:var(--panel-2);border:1px solid var(--line);border-radius:20px;padding:1px 8px}
71
+ .box-head .add{margin-left:auto;background:transparent;border:1px solid var(--line);color:var(--ink-soft);width:24px;height:24px;border-radius:6px;font-size:15px;line-height:1;display:grid;place-items:center}
72
+ .box-head .add:hover{border-color:var(--accent);color:var(--accent)}
73
+ .filter{margin:8px 10px 6px;flex:none}
74
+ .filter input{width:100%;background:var(--bg);border:1px solid var(--line);border-radius:7px;color:var(--ink);padding:7px 10px;font-size:12.5px;font-family:var(--mono)}
75
+ .filter input::placeholder{color:var(--ink-faint)}
76
+ .filter input:focus{outline:none;border-color:var(--accent)}
77
+ .list{flex:1;overflow-y:auto;padding:2px 8px 10px}
78
+ .list::-webkit-scrollbar{width:9px}
79
+ .list::-webkit-scrollbar-thumb{background:var(--line);border-radius:6px}
80
+
81
+ .item{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px;cursor:pointer;font-size:13px;color:var(--ink-soft);border:1px solid transparent;transition:background .12s,border-color .12s}
82
+ .item:hover{background:var(--panel-2);color:var(--ink)}
83
+ .item .name{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
84
+ .item .ext{flex:none;color:var(--ink-faint);font-family:var(--mono);font-size:11px}
85
+ .item .tick{width:7px;height:7px;border-radius:50%;background:var(--accent);opacity:0;flex:none}
86
+ .item.selected{background:color-mix(in srgb,var(--accent) 22%,transparent);border-color:var(--accent);color:var(--ink);font-weight:600}
87
+ .item.selected .tick{opacity:1}
88
+ .item.connect-first{border-color:var(--brass);box-shadow:0 0 0 1px var(--brass) inset}
89
+ .item .del{opacity:0;background:none;border:none;color:var(--ink-faint);font-size:14px;line-height:1;padding:0 3px}
90
+ .item:hover .del{opacity:1}
91
+ .item .del:hover{color:var(--c-resource)}
92
+ .item .depth{flex:none;color:var(--ink-faint);font-family:var(--mono);font-size:11px}
93
+ .empty{color:var(--ink-faint);font-size:12px;font-family:var(--mono);padding:14px 8px;text-align:center}
94
+ .connectable .item{cursor:crosshair}
95
+ </style>
96
+ </head>
97
+ <body>
98
+ <header>
99
+ <div class="brand"><span class="dot"></span>super_auth · graph editor</div>
100
+ <div class="tools">
101
+ <button class="tbtn" id="btn-connect">+ Connect mode</button>
102
+ <button class="tbtn" id="btn-clear">Clear selection</button>
103
+ <button class="tbtn" id="btn-reload">↻ Reload</button>
104
+ </div>
105
+ <div class="spacer"></div>
106
+ <div class="hint" id="hint">Click any record to trace its relationships across every box.</div>
107
+ </header>
108
+
109
+ <div id="compiled">
110
+ <span>Runtime access comes from the compiled authorizations table: <b id="compiled-count">…</b> rows. Edits here take effect after a recompile.</span>
111
+ <button class="tbtn" id="btn-compile">Recompile</button>
112
+ </div>
113
+
114
+ <div id="inspector"></div>
115
+
116
+ <main>
117
+ <section class="region top">
118
+ <div class="box" data-area="group" data-type="group"></div>
119
+ <div class="box" data-area="role" data-type="role"></div>
120
+ <div class="box" data-area="user" data-type="user"></div>
121
+ <div class="box" data-area="permission" data-type="permission"></div>
122
+ </section>
123
+ <section class="region bottom">
124
+ <div class="box" data-area="resource" data-type="resource"></div>
125
+ <div class="box" data-area="user2" data-type="user"></div>
126
+ </section>
127
+ </main>
128
+
129
+ <script>
130
+ // The app may be mounted under a prefix; every API call is relative to this page.
131
+ const API = location.pathname.replace(/\/$/, "");
132
+ const TITLES = {group:"Groups", role:"Roles", user:"Users", permission:"Permissions", resource:"Resources"};
133
+ const NESTED = new Set(["group","role"]);
134
+
135
+ let GRAPH = null; // {groups,roles,users,permissions,resources,edges,authorizations_count}
136
+ let selection = null; // {type,id}
137
+ let relevant = null; // Set of "type:id" or null
138
+ let connectMode = false;
139
+ let connectFirst = null; // {type,id}
140
+ const boxFilters = {}; // area -> text
141
+
142
+ const boxes = [...document.querySelectorAll(".box")];
143
+ const byType = t => ({group:"groups",role:"roles",user:"users",permission:"permissions",resource:"resources"}[t]);
144
+ const key = (t,id) => t+":"+id;
145
+
146
+ // ---- data ----
147
+ async function load(){
148
+ const r = await fetch(API + "/api/graph");
149
+ if(!r.ok){ setHint(`<span style="color:var(--c-resource)">could not load the graph (HTTP ${r.status})</span>`); return; }
150
+ GRAPH = await r.json();
151
+ document.getElementById("compiled-count").textContent = GRAPH.authorizations_count;
152
+ recompute();
153
+ render();
154
+ }
155
+
156
+ // ---- directional traversal ----
157
+ // Authorization flows user → group → role → permission → resource. From the
158
+ // selected node we collect everything DOWNSTREAM (what it can reach) and
159
+ // everything UPSTREAM (what can reach it). So a user shows what they can access;
160
+ // a resource shows who can access it. Group/role hierarchy is bidirectional
161
+ // (a parent's grants apply to children, and a parent "contains" its children).
162
+ const RANK = {user:0, group:1, role:2, permission:3, resource:4};
163
+
164
+ function buildDirected(){
165
+ const fwd = new Map(); // toward resources
166
+ const bwd = new Map(); // toward users
167
+ const add = (m,a,b) => { if(!m.has(a)) m.set(a,new Set()); m.get(a).add(b); };
168
+ const dir = (a,b) => { add(fwd,a,b); add(bwd,b,a); }; // a is upstream of b
169
+ for(const e of GRAPH.edges){
170
+ const ends = [];
171
+ if(e.user_id) ends.push(["user",e.user_id]);
172
+ if(e.group_id) ends.push(["group",e.group_id]);
173
+ if(e.role_id) ends.push(["role",e.role_id]);
174
+ if(e.permission_id) ends.push(["permission",e.permission_id]);
175
+ if(e.resource_id) ends.push(["resource",e.resource_id]);
176
+ for(let i=0;i<ends.length;i++) for(let j=i+1;j<ends.length;j++){
177
+ const [ta,ia]=ends[i], [tb,ib]=ends[j];
178
+ const a=key(ta,ia), b=key(tb,ib);
179
+ if(RANK[ta] <= RANK[tb]) dir(a,b); else dir(b,a);
180
+ }
181
+ }
182
+ // Hierarchy — directional, so siblings never leak:
183
+ // • Group grants flow DOWN to members of descendant groups, so a member
184
+ // inherits ANCESTOR grants → a child group is upstream of its parent.
185
+ // • Role grants flow to DESCENDANT roles, so holding a parent role includes
186
+ // its children → a parent role is upstream of its child.
187
+ // (Selecting a child group therefore reaches its ancestors, never its siblings.)
188
+ for(const g of GRAPH.groups) if(g.parent_id) dir(key("group",g.id), key("group",g.parent_id));
189
+ for(const r of GRAPH.roles) if(r.parent_id) dir(key("role",r.parent_id), key("role",r.id));
190
+ return {fwd,bwd};
191
+ }
192
+
193
+ function reach(map,start){
194
+ const seen=new Set([start]), q=[start];
195
+ while(q.length){ const c=q.shift(); for(const nb of (map.get(c)||[])) if(!seen.has(nb)){ seen.add(nb); q.push(nb); } }
196
+ return seen;
197
+ }
198
+
199
+ function recompute(){
200
+ if(!selection){ relevant = null; return; }
201
+ const {fwd,bwd} = buildDirected();
202
+ const start = key(selection.type, selection.id);
203
+ const down = reach(fwd,start); // what it can reach
204
+ const up = reach(bwd,start); // what can reach it
205
+ const rel = new Set([...down, ...up]);
206
+ // Also surface "peer" users — people whose own access overlaps this subgraph —
207
+ // so selecting a user reveals teammates, not just themselves. (Users are pure
208
+ // source nodes, so they'd never appear otherwise.)
209
+ for(const u of GRAPH.users){
210
+ const uk = key("user", u.id);
211
+ if(rel.has(uk)) continue;
212
+ for(const n of reach(fwd, uk)){ if(rel.has(n)){ rel.add(uk); break; } }
213
+ }
214
+ relevant = rel;
215
+ }
216
+
217
+ // depth for nested types (for indentation)
218
+ function depthOf(type, node){
219
+ const list = GRAPH[byType(type)];
220
+ const map = new Map(list.map(n=>[n.id,n]));
221
+ let d=0, cur=node;
222
+ while(cur && cur.parent_id && map.has(cur.parent_id)){ d++; cur=map.get(cur.parent_id); if(d>20)break; }
223
+ return d;
224
+ }
225
+
226
+ // ---- rendering ----
227
+ function render(){
228
+ for(const box of boxes){
229
+ const type = box.dataset.type;
230
+ const area = box.dataset.area;
231
+ let list = GRAPH[byType(type)] || [];
232
+
233
+ // graph-traversal relevance filter (from selection)
234
+ if(relevant) list = list.filter(n => relevant.has(key(type,n.id)));
235
+ // per-box text filter
236
+ const f = (boxFilters[area]||"").toLowerCase();
237
+ let shown = f ? list.filter(n => haystack(n).includes(f)) : list;
238
+
239
+ const total = GRAPH[byType(type)].length;
240
+ const countTxt = (relevant || f) ? `${shown.length}/${total}` : `${total}`;
241
+
242
+ let items = shown.map(n => {
243
+ const isSel = selection && selection.type===type && selection.id===n.id;
244
+ const isCF = connectFirst && connectFirst.type===type && connectFirst.id===n.id;
245
+ const depth = NESTED.has(type) ? depthOf(type,n) : 0;
246
+ const pad = depth ? `style="padding-left:${8+depth*14}px"` : "";
247
+ const arrow = depth ? `<span class="depth">${"└".padStart(1)} </span>` : "";
248
+ const name = n.name ?? "";
249
+ // The external record occupies one slot: its label when the graph
250
+ // stored one, otherwise the Type#id that used to be the only
251
+ // rendering. Either way the other half is the tooltip.
252
+ const ref = n.external_type ? `${escapeHtml(n.external_type)}#${escapeHtml(n.external_id ?? "*")}` : "";
253
+ const ext = !ref ? "" : (n.super_auth_label
254
+ ? `<span class="ext" title="${ref}">${escapeHtml(n.super_auth_label)}</span>`
255
+ : `<span class="ext" title="external record">${ref}</span>`);
256
+ return `<div class="item ${isSel?'selected':''} ${isCF?'connect-first':''}" data-id="${n.id}" ${pad}>
257
+ <span class="tick"></span>
258
+ ${arrow}<span class="name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>${ext}
259
+ <button class="del" data-del="${n.id}" title="Delete">✕</button>
260
+ </div>`;
261
+ }).join("");
262
+ if(!shown.length) items = `<div class="empty">${relevant ? "no related "+TITLES[type].toLowerCase() : "empty"}</div>`;
263
+
264
+ box.innerHTML = `
265
+ <div class="box-head">
266
+ <span class="swatch"></span>
267
+ <span class="title">${TITLES[type]}</span>
268
+ <span class="count">${countTxt}</span>
269
+ <button class="add" title="Add ${TITLES[type]}">+</button>
270
+ </div>
271
+ <div class="filter"><input type="text" placeholder="filter ${TITLES[type].toLowerCase()}…" value="${escapeHtml(boxFilters[area]||'')}"></div>
272
+ <div class="list ${connectMode?'connectable':''}">${items}</div>`;
273
+
274
+ // wire events
275
+ const input = box.querySelector(".filter input");
276
+ input.addEventListener("input", e => { boxFilters[area]=e.target.value; render(); reFocus(box); });
277
+ box.querySelector(".add").addEventListener("click", () => addNode(type));
278
+ box.querySelectorAll(".item").forEach(el=>{
279
+ el.addEventListener("click", ev=>{
280
+ if(ev.target.closest(".del")) return;
281
+ onItemClick(type, +el.dataset.id);
282
+ });
283
+ });
284
+ box.querySelectorAll(".del").forEach(el=>{
285
+ el.addEventListener("click", ()=> deleteNode(type, +el.dataset.del));
286
+ });
287
+ }
288
+ renderInspector();
289
+ }
290
+
291
+ let focusArea=null;
292
+ function reFocus(box){ // keep caret in the filter after re-render
293
+ const area=box.dataset.area;
294
+ const input=document.querySelector(`.box[data-area="${area}"] .filter input`);
295
+ if(input){ input.focus(); const v=input.value; input.setSelectionRange(v.length,v.length); }
296
+ }
297
+
298
+ function renderInspector(){
299
+ const ins = document.getElementById("inspector");
300
+ if(!selection){ ins.classList.remove("show"); ins.innerHTML=""; return; }
301
+ ins.classList.add("show");
302
+ const node = GRAPH[byType(selection.type)].find(n=>n.id===selection.id);
303
+ if(!node){ ins.classList.remove("show"); return; }
304
+ const color = {group:"var(--c-group)",role:"var(--c-role)",user:"var(--c-user)",permission:"var(--c-perm)",resource:"var(--c-resource)"}[selection.type];
305
+
306
+ // relevance counts per type (excluding the selected type's self)
307
+ const counts = ["group","role","user","permission","resource"].map(t=>{
308
+ const n = GRAPH[byType(t)].filter(x=>relevant.has(key(t,x.id)) && !(t===selection.type && x.id===selection.id)).length;
309
+ return `<span><b>${n}</b> ${TITLES[t].toLowerCase()}</span>`;
310
+ }).join("");
311
+
312
+ // direct edges of the selected node (editable)
313
+ const col = {user:"user_id",group:"group_id",role:"role_id",permission:"permission_id",resource:"resource_id"}[selection.type];
314
+ const direct = GRAPH.edges.filter(e=>e[col]===selection.id);
315
+ const edgeChips = direct.map(e=>{
316
+ const other = ["user","group","role","permission","resource"].filter(t=>{
317
+ const c={user:"user_id",group:"group_id",role:"role_id",permission:"permission_id",resource:"resource_id"}[t];
318
+ return e[c] && !(t===selection.type && e[c]===selection.id);
319
+ }).map(t=>{
320
+ const c={user:"user_id",group:"group_id",role:"role_id",permission:"permission_id",resource:"resource_id"}[t];
321
+ return `${escapeHtml(nameFor(t,e[c]))} <span style="color:var(--ink-faint)">(${t})</span>`;
322
+ }).join(", ");
323
+ return `<span class="edge-chip">→ ${other||"—"} <button class="x" data-edge="${e.id}" title="Delete edge">✕</button></span>`;
324
+ }).join("");
325
+
326
+ ins.innerHTML = `
327
+ <span class="sel-chip"><span class="swatch" style="background:${color}"></span>${escapeHtml(node.name ?? "")}
328
+ <span style="color:var(--ink-faint)">${selection.type}</span></span>
329
+ <span class="rel-counts">${counts}</span>
330
+ <span class="conns"><span class="lbl">edges:</span>${edgeChips||'<span style="color:var(--ink-faint);font-family:var(--mono);font-size:11px">none</span>'}</span>`;
331
+ ins.querySelectorAll("[data-edge]").forEach(b=> b.addEventListener("click", ()=> deleteEdge(+b.dataset.edge)));
332
+ }
333
+
334
+ // ---- interactions ----
335
+ function onItemClick(type,id){
336
+ if(connectMode){
337
+ if(!connectFirst){ connectFirst={type,id}; setHint(`Connecting from <b>${escapeHtml(nameFor(type,id))}</b> — pick a second record.`); render(); return; }
338
+ if(connectFirst.type===type && connectFirst.id===id){ connectFirst=null; setHint("Connect mode: pick the first record."); render(); return; }
339
+ createEdge(connectFirst, {type,id});
340
+ return;
341
+ }
342
+ if(selection && selection.type===type && selection.id===id){ selection=null; } // toggle off
343
+ else { selection={type,id}; }
344
+ recompute(); render();
345
+ }
346
+
347
+ async function createEdge(a,b){
348
+ const res = await fetch(API + "/api/edges",{method:"POST",headers:{"Content-Type":"application/json"},
349
+ body:JSON.stringify({a_type:a.type,a_id:a.id,b_type:b.type,b_id:b.id})});
350
+ const j = await res.json();
351
+ connectFirst=null;
352
+ if(!res.ok){ setHint(`<span style="color:var(--c-resource)">${escapeHtml(j.error||"could not connect")}</span>`); }
353
+ else setHint(`Connected <b>${escapeHtml(nameFor(a.type,a.id))}</b> → <b>${escapeHtml(nameFor(b.type,b.id))}</b>. Recompile to apply.`);
354
+ await load();
355
+ }
356
+
357
+ async function deleteEdge(id){
358
+ const res = await fetch(API + "/api/edges/"+id,{method:"DELETE"});
359
+ if(!res.ok){ alert(await errorText(res)); }
360
+ await load();
361
+ }
362
+
363
+ async function addNode(type){
364
+ const name = prompt(`New ${type} name:`);
365
+ if(!name) return;
366
+ let parent_id = null;
367
+ if(NESTED.has(type)){
368
+ const p = prompt(`Parent ${type} name (optional — leave blank for top level):`);
369
+ if(p){ const match = GRAPH[byType(type)].find(n=>(n.name||"").toLowerCase()===p.trim().toLowerCase());
370
+ if(match) parent_id=match.id; else { alert(`No ${type} named "${p}" — creating at top level.`); } }
371
+ }
372
+ const res = await fetch(API + "/api/nodes/"+type,{method:"POST",headers:{"Content-Type":"application/json"},
373
+ body:JSON.stringify({name,parent_id})});
374
+ if(!res.ok){ alert(await errorText(res)); return; }
375
+ await load();
376
+ }
377
+
378
+ async function deleteNode(type,id){
379
+ if(!confirm(`Delete this ${type}? Its edges will be removed too, and any children become top-level.`)) return;
380
+ const res = await fetch(API + `/api/nodes/${type}/${id}`,{method:"DELETE"});
381
+ if(!res.ok){ alert(await errorText(res)); }
382
+ if(selection && selection.type===type && selection.id===id) selection=null;
383
+ await load();
384
+ }
385
+
386
+ async function compile(){
387
+ const btn = document.getElementById("btn-compile");
388
+ btn.disabled = true;
389
+ try{
390
+ const res = await fetch(API + "/api/compile",{method:"POST"});
391
+ if(!res.ok){ alert(await errorText(res)); return; }
392
+ const j = await res.json();
393
+ setHint(`Compiled <b>${j.count}</b> authorization rows.`);
394
+ } finally { btn.disabled = false; }
395
+ await load();
396
+ }
397
+
398
+ // ---- toolbar ----
399
+ document.getElementById("btn-connect").addEventListener("click", ()=>{
400
+ connectMode=!connectMode; connectFirst=null;
401
+ document.getElementById("btn-connect").classList.toggle("on",connectMode);
402
+ setHint(connectMode ? "Connect mode: click two records to draw an edge between them." : "Click any record to trace its relationships.");
403
+ render();
404
+ });
405
+ document.getElementById("btn-clear").addEventListener("click", ()=>{
406
+ selection=null; connectFirst=null; recompute(); render();
407
+ });
408
+ document.getElementById("btn-reload").addEventListener("click", load);
409
+ document.getElementById("btn-compile").addEventListener("click", compile);
410
+
411
+ // ---- utils ----
412
+ // Everything a row renders is searchable, including what the tooltip holds:
413
+ // a labelled node keeps its Type#id there, and pasting a uuid has to find it.
414
+ function haystack(n){ const ref=[n.external_type,n.external_id].filter(Boolean).join("#"); return [n.name,n.super_auth_label,ref].filter(Boolean).join(" ").toLowerCase(); }
415
+ function nameFor(t,id){ const x=GRAPH[byType(t)].find(n=>n.id===id); return x?(x.name ?? "#"+id):("#"+id); }
416
+ function setHint(html){ document.getElementById("hint").innerHTML=html; }
417
+ function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c])); }
418
+ async function errorText(res){ try{ const j=await res.json(); return j.error||("HTTP "+res.status); }catch(e){ return "HTTP "+res.status; } }
419
+
420
+ load();
421
+ </script>
422
+ </body>
423
+ </html>