@binary1702/auth 0.3.0 → 0.4.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.
@@ -2,19 +2,27 @@
2
2
  // The admin page. Plain HTML like the other auth pages.
3
3
  //
4
4
  // ?tab=orgs (default) organizations: name, members, roles in use → click one
5
- // ?org=<id> one organization: its roles → permissions, its members
5
+ // ?org=<id> one organization: its members, and its own rule per
6
+ // role (a grid of checkboxes; unset = follows Roles)
6
7
  // ?tab=users every user: verification, memberships, actions
8
+ // ?tab=roles what each role may do in every organization without
9
+ // its own rule: one checkbox per permission the app
10
+ // declares (permissions: in b17_auth.yaml)
7
11
  // ?tab=settings the resolved settings, read-only
8
12
  //
13
+ // An organization's owner sees only their organization (`limited`), and
14
+ // only its roles grid and members.
15
+ //
9
16
  // Every change is a small form that POSTs back and returns to the same
10
17
  // view. Nothing here decides anything; the handler and core/admin.ts
11
18
  // do. Every value goes through escape().
12
19
  // ─────────────────────────────────────────────────────────────
13
20
  import { escape } from "./html.js";
14
- import { ADMIN_PATH } from "./admin.js";
21
+ import { ADMIN_PATH, checkedFor } from "./admin.js";
15
22
  import { SIGN_OUT_PATH } from "./config.js";
23
+ import { landing } from "./pages.js";
16
24
  import { PASSWORD_MAX, PASSWORD_MIN } from "./signup.js";
17
- import { permissionsFor } from "./org.js";
25
+ import { OWNER, permissionsFor } from "./org.js";
18
26
  const STYLE = `
19
27
  :root { color-scheme: light dark; --fg: #111; --bg: #fff; --line: #e3e3e3; --muted: #6b6b6b; --err: #c0392b; --ok: #1e8449; --panel: #f6f6f7; --accent: #2f7fd6; }
20
28
  @media (prefers-color-scheme: dark) { :root { --fg: #ececec; --bg: #121212; --line: #2c2c2c; --muted: #9a9a9a; --err: #ff6b6b; --ok: #58d68d; --panel: #1c1c1e; --accent: #6fb1ff; } }
@@ -43,17 +51,24 @@ const STYLE = `
43
51
  tr.row:hover td { background: color-mix(in srgb, var(--panel) 60%, var(--bg)); }
44
52
  code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
45
53
  .muted { color: var(--muted); }
54
+ .small { font-size: 12px; }
46
55
  .ok { color: var(--ok); }
47
56
  .warn { color: var(--err); }
48
57
  .right { text-align: right; white-space: nowrap; }
58
+ .center { text-align: center; }
49
59
  form.inline { display: inline-flex; gap: 4px; align-items: center; margin: 0 4px 0 0; }
50
60
  form.grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); align-items: end; }
51
61
  label { display: grid; gap: 3px; font-size: 12px; color: var(--muted); }
52
62
  input, select { font: inherit; font-size: 13px; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 0; }
63
+ input[type=checkbox] { width: 18px; height: 18px; padding: 0; accent-color: var(--fg); cursor: pointer; }
64
+ input[type=checkbox]:disabled { cursor: not-allowed; opacity: .6; }
53
65
  button { font: inherit; font-size: 12.5px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); cursor: pointer; white-space: nowrap; }
54
66
  button.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); }
55
67
  button.danger { color: var(--err); border-color: var(--err); }
56
68
  button.quiet { border-color: transparent; color: var(--muted); }
69
+ button.small { font-size: 11.5px; padding: 3px 8px; }
70
+ table.roles th.center, table.roles td.center { padding-left: 6px; padding-right: 6px; }
71
+ table.roles tfoot td { border-top: 2px solid var(--line); vertical-align: top; }
57
72
  .chip { display: inline-block; font-family: ui-monospace, Menlo, monospace; font-size: 11px; padding: 1px 7px; border: 1px solid var(--line); border-radius: 999px; margin: 1px 4px 1px 0; color: var(--muted); }
58
73
  .chip.all { color: var(--fg); border-color: var(--fg); }
59
74
  .pill { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); }
@@ -67,6 +82,8 @@ const STYLE = `
67
82
  button.quiet.danger { color: var(--err); }
68
83
  pre { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font-size: 12px; overflow: auto; margin: 8px 0 0; }
69
84
  .empty { color: var(--muted); font-size: 13px; padding: 6px 0; }
85
+ .link { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: -6px 0 14px; padding: 8px 12px; border: 1px dashed var(--line); border-radius: 8px; }
86
+ .link code { word-break: break-all; }
70
87
  `;
71
88
  const hidden = (fields) => Object.entries(fields)
72
89
  .map(([k, v]) => `<input type="hidden" name="${escape(k)}" value="${escape(v)}">`)
@@ -78,10 +95,75 @@ const chips = (perms) => perms.length ? perms.map((p) => (p === "*" ? `<span cla
78
95
  export function adminPage(d) {
79
96
  const usersPath = `${ADMIN_PATH}/users`;
80
97
  const orgsPath = `${ADMIN_PATH}/orgs`;
98
+ const rolesPath = `${ADMIN_PATH}/roles`;
81
99
  const o = d.options;
82
100
  const tab = d.view.tab === "org" ? "orgs" : d.view.tab;
83
101
  const link = (t) => `${ADMIN_PATH}${t === "orgs" ? "" : `?tab=${t}`}`;
84
102
  const orgLink = (id) => `${ADMIN_PATH}?org=${escape(id)}`;
103
+ const catalogue = Object.entries(o.permissions);
104
+ const catalogueIds = catalogue.map(([id]) => id);
105
+ /** what was saved for a role: in this organization and for all of them */
106
+ const storedFor = (role, orgId) => ({
107
+ org: orgId ? (d.stored.find((s) => s.scope === orgId && s.name === role)?.permissions ?? null) : null,
108
+ all: d.stored.find((s) => s.scope === "" && s.name === role)?.permissions ?? null,
109
+ });
110
+ const effective = (role, orgId) => permissionsFor(role, storedFor(role, orgId));
111
+ /**
112
+ * The grid: rows are the catalogue, columns the roles, one form per role
113
+ * (outside the table; each checkbox names it through the `form`
114
+ * attribute). scope "" is the rule for every organization; an id is one
115
+ * organization's own rule. The owner column is fixed: everything.
116
+ */
117
+ const grid = (scope, inUse) => {
118
+ const formId = (r) => `role-${r.replace(/[^a-z0-9_-]/gi, "_")}`;
119
+ const own = (r) => (scope ? storedFor(r, scope).org : storedFor(r).all);
120
+ const forms = d.roles
121
+ .filter((r) => r !== OWNER)
122
+ .map((r) => `<form id="${formId(r)}" method="post" action="${rolesPath}">${hidden({ action: "set-permissions", role: r, scope, ...(scope ? { org: scope } : { tab: "roles" }) })}</form>`)
123
+ .join("");
124
+ const head = d.roles
125
+ .map((r) => {
126
+ const used = inUse.filter((x) => x === r).length;
127
+ const note = r === OWNER ? "everything, always" : scope ? (own(r) ? "own rule" : "follows Roles") : own(r) ? "saved" : "default";
128
+ return `<th class="center"><span class="pill">${escape(r)}</span><br><span class="muted small">${used} member${used === 1 ? "" : "s"} · ${note}</span></th>`;
129
+ })
130
+ .join("");
131
+ const rows = catalogue
132
+ .map(([id, label]) => {
133
+ const cells = d.roles
134
+ .map((r) => {
135
+ const on = checkedFor(effective(r, scope || undefined), [id]).length > 0;
136
+ const fixed = r === OWNER;
137
+ return `<td class="center"><input type="checkbox"${fixed ? "" : ` form="${formId(r)}"`} name="p:${escape(id)}" value="on"${on ? " checked" : ""}${fixed ? " disabled" : ""} aria-label="${escape(r)} may ${escape(label)}"></td>`;
138
+ })
139
+ .join("");
140
+ return `<tr class="row"><td><b>${escape(label)}</b><br><code class="muted">${escape(id)}</code></td>${cells}</tr>`;
141
+ })
142
+ .join("\n");
143
+ const foot = d.roles
144
+ .map((r) => {
145
+ if (r === OWNER)
146
+ return `<td class="center muted small">fixed</td>`;
147
+ const save = `<button type="submit" form="${formId(r)}" class="primary small">Save</button>`;
148
+ const back = scope
149
+ ? own(r)
150
+ ? `<form class="inline" method="post" action="${rolesPath}">${hidden({ action: "follow", role: r, scope, org: scope })}<button type="submit" class="quiet small" title="drop this organization's own rule; follow Roles again">follow Roles</button></form>`
151
+ : ""
152
+ : o.roles[r]
153
+ ? `<form class="inline" method="post" action="${rolesPath}">${hidden({ action: "defaults", role: r, scope: "", tab: "roles" })}<button type="submit" class="quiet small" title="back to the starting point in b17_auth.yaml">defaults</button></form>`
154
+ : "";
155
+ return `<td class="center">${save}${back ? `<br>${back}` : ""}</td>`;
156
+ })
157
+ .join("");
158
+ return `${forms}<table class="roles"><thead><tr><th>permission</th>${head}</tr></thead><tbody>${rows}</tbody><tfoot><tr><td class="muted small">A saved role grants exactly its ticked boxes; a permission the app adds later starts off until ticked.</td>${foot}</tr></tfoot></table>`;
159
+ };
160
+ const noCatalogue = `<p class="empty">Nothing to toggle yet: the app has not declared what it gates. In b17_auth.yaml, list each permission with a label:</p>
161
+ <pre>permissions:
162
+ invoices.read: See invoices
163
+ invoices.create: Create invoices
164
+ roles:
165
+ owner: all
166
+ member: invoices.read</pre>`;
85
167
  let body = "";
86
168
  // ── organizations ───────────────────────────────────────────
87
169
  if (d.view.tab === "orgs") {
@@ -113,35 +195,23 @@ ${d.orgs.length ? `<table><thead><tr><th>organization</th><th>members</th><th>ro
113
195
  const orgId = d.view.orgId;
114
196
  const org = d.orgs.find((x) => x.id === orgId);
115
197
  if (!org) {
116
- body = `<p class="error">No such organization.</p><p><a href="${ADMIN_PATH}">← Organizations</a></p>`;
198
+ body = `<p class="error">No such organization.</p>${d.limited ? "" : `<p><a href="${ADMIN_PATH}">← Organizations</a></p>`}`;
117
199
  }
118
200
  else {
119
201
  const inUse = org.members.map((m) => m.role);
120
- const roleRows = d.roles
121
- .filter((r) => inUse.includes(r) || o.permissions[r] || o.organizations[org.name]?.[r])
122
- .map((r) => {
123
- const override = o.organizations[org.name]?.[r] !== undefined;
124
- return `<tr class="row">
125
- <td><span class="pill">${escape(r)}</span></td>
126
- <td>${chips(permissionsFor(r, org.name, o))}</td>
127
- <td class="muted">${override ? `organizations.${escape(org.name)}.${escape(r)}` : o.permissions[r] ? `permissions.${escape(r)}` : "not in b17_auth.yaml"}</td>
128
- <td class="right">${inUse.filter((x) => x === r).length} member${inUse.filter((x) => x === r).length === 1 ? "" : "s"}</td>
129
- </tr>`;
130
- })
131
- .join("\n");
132
202
  const memberRows = org.members
133
203
  .map((m) => `<tr class="row">
134
204
  <td><b>${escape(m.email)}</b>${m.email === d.self ? ' <span class="muted">(you)</span>' : ""}</td>
135
205
  <td><form class="inline" method="post" action="${orgsPath}">${hidden({ action: "set-role", org: org.id, email: m.email })}${roleSelect("role", d.roles, m.role)}<button type="submit" class="quiet">set</button></form></td>
136
- <td>${chips(permissionsFor(m.role, org.name, o))}</td>
206
+ <td>${chips(effective(m.role, org.id))}</td>
137
207
  <td class="right"><form class="inline" method="post" action="${orgsPath}">${hidden({ action: "remove-member", org: org.id, email: m.email })}<button type="submit" class="danger">remove</button></form></td>
138
208
  </tr>`)
139
209
  .join("\n");
140
210
  body = `
141
- <div class="crumb"><a href="${ADMIN_PATH}">Organizations</a> › <b>${escape(org.name)}</b> <code class="muted">${escape(org.id)}</code></div>
211
+ <div class="crumb">${d.limited ? "" : `<a href="${ADMIN_PATH}">Organizations</a> › `}<b>${escape(org.name)}</b> <code class="muted">${escape(org.id)}</code></div>
142
212
  <div class="card">
143
- <h2>Roles in ${escape(org.name)} <span class="muted">· what each may do, from b17_auth.yaml</span></h2>
144
- ${roleRows ? `<table><thead><tr><th>role</th><th>permissions</th><th>defined in</th><th class="right">used by</th></tr></thead><tbody>${roleRows}</tbody></table>` : '<p class="empty">No roles yet.</p>'}
213
+ <h2>Roles in ${escape(org.name)} <span class="muted">· this organization's own rule per role; a column that follows Roles uses the rule for every organization</span></h2>
214
+ ${catalogue.length ? grid(org.id, inUse) : noCatalogue}
145
215
  </div>
146
216
  <div class="card">
147
217
  <h2>Members <span class="muted">(${org.members.length})</span></h2>
@@ -154,7 +224,7 @@ ${memberRows ? `<table><thead><tr><th>user</th><th>role</th><th>permissions here
154
224
  <button type="submit" class="primary">Add</button>
155
225
  </form>
156
226
  </div>
157
- <form class="inline" method="post" action="${orgsPath}" ${confirmRemove(org.name)}>${hidden({ action: "remove-org", org: org.id })}<button type="submit" class="danger">Remove ${escape(org.name)}</button></form>`;
227
+ ${d.limited ? "" : `<form class="inline" method="post" action="${orgsPath}" ${confirmRemove(org.name)}>${hidden({ action: "remove-org", org: org.id })}<button type="submit" class="danger">Remove ${escape(org.name)}</button></form>`}`;
158
228
  }
159
229
  }
160
230
  // ── users ───────────────────────────────────────────────────
@@ -169,7 +239,8 @@ ${memberRows ? `<table><thead><tr><th>user</th><th>role</th><th>permissions here
169
239
  ? '<span class="ok">verified</span>'
170
240
  : `<span class="warn">unverified</span>
171
241
  <form class="inline" method="post" action="${usersPath}">${hidden({ action: "send-verify", email: u.email, tab: "users" })}<button type="submit" class="quiet" title="send a verification email">email</button></form>
172
- <form class="inline" method="post" action="${usersPath}">${hidden({ action: "verify", email: u.email, tab: "users" })}<button type="submit" class="quiet" title="mark verified without the email">mark</button></form>`;
242
+ <form class="inline" method="post" action="${usersPath}">${hidden({ action: "verify", email: u.email, tab: "users" })}<button type="submit" class="quiet" title="mark verified without the email">mark</button></form>
243
+ <form class="inline" method="post" action="${usersPath}">${hidden({ action: "invite", email: u.email, tab: "users" })}<button type="submit" class="quiet" title="email a link to choose a password (also verifies the address)">invite</button></form>`;
173
244
  return `<tr class="row">
174
245
  <td title="${escape(u.id)}"><b>${escape(u.email)}</b>${self ? ' <span class="muted">you</span>' : ""}${o.admins.includes(u.email) ? ' <span class="pill">admin</span>' : ""}</td>
175
246
  <td class="nowrap">${status}</td>
@@ -188,24 +259,34 @@ ${memberRows ? `<table><thead><tr><th>user</th><th>role</th><th>permissions here
188
259
  <div class="card">
189
260
  <h2>Users <span class="muted">(${d.users.length})</span></h2>
190
261
  <table><thead><tr><th>user</th><th>email</th><th>organizations</th><th></th></tr></thead><tbody>${rows}</tbody></table>
191
- <p class="empty">Permissions are per organization: open one under Organizations.</p>
262
+ <p class="empty">What a role may do is under Roles, or inside an organization for its own rule.</p>
192
263
  </div>
193
264
  <div class="card panel">
194
265
  <form class="grid" method="post" action="${usersPath}">${hidden({ action: "add", tab: "users" })}
195
266
  <label>New user <input type="email" name="email" required autocomplete="off" placeholder="email"></label>
196
- <label>Password <input type="password" name="password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" required autocomplete="new-password"></label>
197
- <button type="submit" class="primary">Add user</button>
267
+ <label>Password <input type="password" name="password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" autocomplete="new-password" placeholder="for Register only"></label>
268
+ <button type="submit" name="mode" value="register" class="primary" title="email + password: the account is ready and verified">Register</button>
269
+ <button type="submit" name="mode" value="invite" title="email only: a seven-day link to choose a password, which verifies the address and signs them in">Invite</button>
198
270
  </form>
271
+ <p class="muted small"><b>Register</b>: email + password, ready and verified now. <b>Invite</b>: email only; the person gets one link, good for seven days, to choose a password, which verifies the address and signs them in.</p>
199
272
  </div>`;
273
+ }
274
+ // ── roles ───────────────────────────────────────────────────
275
+ if (d.view.tab === "roles") {
276
+ const inUse = d.orgs.flatMap((org) => org.members.map((m) => m.role));
277
+ body = `
278
+ <div class="card">
279
+ <h2>Roles <span class="muted">· what each role may do in every organization without its own rule. Tick, then Save that column.</span></h2>
280
+ ${catalogue.length ? grid("", inUse) : noCatalogue}
281
+ </div>
282
+ <p class="empty">An organization can have its own rule per role: open it under Organizations. Owners and admins (${escape(o.admins.join(", ") || "none")}) hold everything regardless.</p>`;
200
283
  }
201
284
  // ── settings ────────────────────────────────────────────────
202
285
  if (d.view.tab === "settings") {
203
- const permissionRows = Object.entries(o.permissions)
286
+ const catalogueRows = catalogue.map(([id, label]) => ` ${id}: ${label}`).join("\n");
287
+ const defaultRows = Object.entries(o.roles)
204
288
  .map(([role, perms]) => ` ${role}: ${perms.join(" ") || "(nothing)"}`)
205
289
  .join("\n");
206
- const orgOverrides = Object.entries(o.organizations)
207
- .map(([org, roles]) => ` ${org}:\n` + Object.entries(roles).map(([r, p]) => ` ${r}: ${p.join(" ") || "(nothing)"}`).join("\n"))
208
- .join("\n");
209
290
  body = `
210
291
  <div class="card">
211
292
  <h2>Settings <span class="muted">· read-only, from b17_auth.yaml and .env.local</span></h2>
@@ -215,12 +296,16 @@ ${memberRows ? `<table><thead><tr><th>user</th><th>role</th><th>permissions here
215
296
  <tr><td>requireVerified</td><td>${o.requireVerified}</td></tr>
216
297
  <tr><td>sessionDays</td><td>${o.sessionDays}</td></tr>
217
298
  <tr><td>${o.protected.length ? "protected" : "public"}</td><td>${escape((o.protected.length ? o.protected : o.public).join(" ") || "(everything needs sign-in)")}</td></tr>
299
+ <tr><td>home</td><td>${escape(landing(null, o.home))}</td></tr>
218
300
  <tr><td>admins</td><td>${escape(o.admins.join(" "))}</td></tr>
219
301
  <tr><td>email</td><td>${escape(d.transport)}${o.email.from ? ` · from ${escape(o.email.from)}` : ""}${o.email.replyTo ? ` · reply-to ${escape(o.email.replyTo)}` : ""}</td></tr>
220
302
  <tr><td>tokens</td><td>${d.tokens.total} issued · ${d.tokens.live} still usable</td></tr>
221
303
  </tbody></table>
222
- <pre>permissions:
223
- ${escape(permissionRows) || " (none)"}${orgOverrides ? `\norganizations:\n${escape(orgOverrides)}` : ""}</pre>
304
+ <pre>permissions: # what the app gates
305
+ ${escape(catalogueRows) || " (none declared)"}
306
+ roles: # the starting point, written to the database once
307
+ ${escape(defaultRows) || " (none)"}</pre>
308
+ <p class="muted small">What a role may do today is under <a href="${link("roles")}">Roles</a>; the database decides, this file only seeds it.</p>
224
309
  </div>`;
225
310
  }
226
311
  return `<!doctype html>
@@ -233,19 +318,32 @@ ${escape(permissionRows) || " (none)"}${orgOverrides ? `\norganizations:\n${esc
233
318
  </head>
234
319
  <body>
235
320
  <main>
236
- <div class="top"><h1>${escape(o.name || "Auth")} admin</h1><a href="/">app</a><a href="${SIGN_OUT_PATH}">sign out</a><span class="who">${escape(d.self)}</span></div>
237
- <nav class="tabs">
321
+ <div class="top"><h1>${escape(o.name || "Auth")} admin</h1><a href="${escape(landing(null, o.home))}">app</a><a href="${SIGN_OUT_PATH}">sign out</a><span class="who">${escape(d.self)}</span></div>
322
+ ${d.limited
323
+ ? ""
324
+ : `<nav class="tabs">
238
325
  <a href="${link("orgs")}" class="${tab === "orgs" ? "on" : ""}">Organizations</a>
239
326
  <a href="${link("users")}" class="${tab === "users" ? "on" : ""}">Users</a>
327
+ <a href="${link("roles")}" class="${tab === "roles" ? "on" : ""}">Roles</a>
240
328
  <a href="${link("settings")}" class="${tab === "settings" ? "on" : ""}">Settings</a>
241
- </nav>
242
- ${d.notice ? `<p class="notice">${escape(d.notice)}</p>` : ""}${d.error ? `<p class="error">${escape(d.error)}</p>` : ""}
329
+ </nav>`}
330
+ ${d.transport === "console"
331
+ ? `<p class="error">Email is not set up: verification and password-reset links are printed to the dev server's terminal, not sent. Add <code>email:</code> (gmail or resend) to b17_auth.yaml, with the secrets in .env.local.</p>`
332
+ : d.transport === "none"
333
+ ? `<p class="error">Email is not set up: in production, verification and password-reset emails fail until <code>email:</code> (gmail or resend) is configured in b17_auth.yaml.</p>`
334
+ : ""}
335
+ ${d.notice ? `<p class="notice">${escape(d.notice)}</p>` : ""}
336
+ ${d.error ? `<p class="error">${escape(d.error)}</p>` : ""}
337
+ ${d.link ? `<p class="link"><code>${escape(d.link)}</code> <button type="button" class="small" data-copy="${escape(d.link)}">copy</button> <span class="muted small">works once; the next "invite" or "email" issues a new one</span></p>` : ""}
243
338
  ${body}
244
339
  </main>
245
340
  <script nonce="${escape(d.nonce)}">
246
341
  for (const f of document.querySelectorAll("form[data-confirm]")) {
247
342
  f.addEventListener("submit", (e) => { if (!confirm(f.dataset.confirm)) e.preventDefault() })
248
343
  }
344
+ for (const b of document.querySelectorAll("button[data-copy]")) {
345
+ b.addEventListener("click", () => navigator.clipboard.writeText(b.dataset.copy).then(() => { b.textContent = "copied" }))
346
+ }
249
347
  </script>
250
348
  </body>
251
349
  </html>`;
@@ -1,13 +1,31 @@
1
- import type { Session } from "./org.ts";
1
+ import { type Session } from "./org.ts";
2
2
  export declare const ADMIN_PATH = "/auth/admin";
3
3
  export declare function isAdmin(session: Pick<Session, "user"> | null, admins: readonly string[]): boolean;
4
+ /**
5
+ * Who may change what a role may do, per scope. Pure.
6
+ * every organization (scope "") a system admin (admins: in the yaml)
7
+ * one organization (its id) a system admin, or that organization's owner
8
+ * The owner role itself is never editable: it holds everything.
9
+ */
10
+ export declare function mayEditRoles(admin: boolean, scope: string, roleInScope: string | null): boolean;
4
11
  /** The forms on the page. Each is one POST with `action` and a few fields. */
5
- export declare const USER_ACTIONS: readonly ["add", "set-password", "sign-out", "verify", "send-verify", "remove"];
12
+ /**
13
+ * add: two flows by the button pressed (`mode`): Register (email + password, ready and verified)
14
+ * or Invite (email only; a seven-day link to set a password, which verifies and signs in)
15
+ * invite: send the Invite link again to an existing user
16
+ */
17
+ export declare const USER_ACTIONS: readonly ["add", "invite", "set-password", "sign-out", "verify", "send-verify", "remove"];
6
18
  export declare const ORG_ACTIONS: readonly ["create", "add-member", "set-role", "remove-member", "remove-org"];
19
+ /** set-permissions: save the ticked boxes · defaults: back to the yaml starting point (scope "") · follow: drop an organization's own rule (scope id) */
20
+ export declare const ROLE_ACTIONS: readonly ["set-permissions", "defaults", "follow"];
7
21
  export type UserAction = (typeof USER_ACTIONS)[number];
8
22
  export type OrgAction = (typeof ORG_ACTIONS)[number];
23
+ export type RoleAction = (typeof ROLE_ACTIONS)[number];
9
24
  export declare const isUserAction: (a: string) => a is UserAction;
10
25
  export declare const isOrgAction: (a: string) => a is OrgAction;
26
+ export declare const isRoleAction: (a: string) => a is RoleAction;
27
+ /** A role is one word: letters, digits, - or _ */
28
+ export declare const ROLE_NAME: RegExp;
11
29
  /**
12
30
  * What an admin may not do to themselves: remove or lock out the account
13
31
  * they are using. (Their own password change is fine: it ends their other
@@ -15,12 +33,24 @@ export declare const isOrgAction: (a: string) => a is OrgAction;
15
33
  */
16
34
  export declare function refusedOnSelf(action: UserAction, targetEmail: string, selfEmail: string): string | null;
17
35
  /**
18
- * Where to land after an action: the view the form was on. Only two shapes
19
- * exist, so the value is rebuilt from parts, never taken from the form as is.
36
+ * Where to land after an action: the view the form was on. Only a few
37
+ * shapes exist, so the value is rebuilt from parts, never taken from the form as is.
20
38
  */
21
39
  export declare function backTo(fields: {
22
40
  org?: string;
23
41
  tab?: string;
24
42
  }): string;
25
- /** Roles the page offers in its dropdowns: whatever the yaml defines, plus any role already in use. */
43
+ /** Roles the page offers in its dropdowns: whatever is defined or saved, plus any role already in use. Owner first. */
26
44
  export declare function roleChoices(defined: readonly string[], inUse: readonly string[]): string[];
45
+ /**
46
+ * The Roles grid: one checkbox per catalogue entry, named `p:<id>`. Which
47
+ * are on for a role follows from its effective grants, wildcards included
48
+ * ("all" ticks every box). Pure.
49
+ */
50
+ export declare function checkedFor(granted: readonly string[], catalogueIds: readonly string[]): string[];
51
+ /**
52
+ * The grid posted back: the catalogue entries whose box was on, in
53
+ * catalogue order. Ids outside the catalogue are ignored, so a form can
54
+ * never grant something the app does not gate. Pure.
55
+ */
56
+ export declare function grantedFromFields(fields: Record<string, string>, catalogueIds: readonly string[]): string[];
@@ -3,20 +3,45 @@
3
3
  //
4
4
  // admins: ben@example.com … (b17_auth.yaml, or AUTH_ADMINS) may open
5
5
  // /auth/admin and do what the CLI does:
6
- // users and memberships. Never the yaml.
6
+ // users, memberships, and what each role
7
+ // may do (saved in the database; the yaml
8
+ // is the starting point). Never the yaml.
7
9
  //
8
10
  // An admin is an email, not a role: a role lives in one organization,
9
11
  // the admin page spans all of them.
10
12
  // ─────────────────────────────────────────────────────────────
13
+ import { OWNER, hasPermission } from "./org.js";
14
+ import { PERMISSION_ID } from "./options.js";
11
15
  export const ADMIN_PATH = "/auth/admin";
12
16
  export function isAdmin(session, admins) {
13
17
  return !!session && admins.includes(session.user.email.toLowerCase());
14
18
  }
19
+ /**
20
+ * Who may change what a role may do, per scope. Pure.
21
+ * every organization (scope "") a system admin (admins: in the yaml)
22
+ * one organization (its id) a system admin, or that organization's owner
23
+ * The owner role itself is never editable: it holds everything.
24
+ */
25
+ export function mayEditRoles(admin, scope, roleInScope) {
26
+ if (admin)
27
+ return true;
28
+ return scope !== "" && roleInScope === OWNER;
29
+ }
15
30
  /** The forms on the page. Each is one POST with `action` and a few fields. */
16
- export const USER_ACTIONS = ["add", "set-password", "sign-out", "verify", "send-verify", "remove"];
31
+ /**
32
+ * add: two flows by the button pressed (`mode`): Register (email + password, ready and verified)
33
+ * or Invite (email only; a seven-day link to set a password, which verifies and signs in)
34
+ * invite: send the Invite link again to an existing user
35
+ */
36
+ export const USER_ACTIONS = ["add", "invite", "set-password", "sign-out", "verify", "send-verify", "remove"];
17
37
  export const ORG_ACTIONS = ["create", "add-member", "set-role", "remove-member", "remove-org"];
38
+ /** set-permissions: save the ticked boxes · defaults: back to the yaml starting point (scope "") · follow: drop an organization's own rule (scope id) */
39
+ export const ROLE_ACTIONS = ["set-permissions", "defaults", "follow"];
18
40
  export const isUserAction = (a) => USER_ACTIONS.includes(a);
19
41
  export const isOrgAction = (a) => ORG_ACTIONS.includes(a);
42
+ export const isRoleAction = (a) => ROLE_ACTIONS.includes(a);
43
+ /** A role is one word: letters, digits, - or _ */
44
+ export const ROLE_NAME = /^[a-z0-9_-]{1,32}$/i;
20
45
  /**
21
46
  * What an admin may not do to themselves: remove or lock out the account
22
47
  * they are using. (Their own password change is fine: it ends their other
@@ -32,17 +57,34 @@ export function refusedOnSelf(action, targetEmail, selfEmail) {
32
57
  return null;
33
58
  }
34
59
  /**
35
- * Where to land after an action: the view the form was on. Only two shapes
36
- * exist, so the value is rebuilt from parts, never taken from the form as is.
60
+ * Where to land after an action: the view the form was on. Only a few
61
+ * shapes exist, so the value is rebuilt from parts, never taken from the form as is.
37
62
  */
38
63
  export function backTo(fields) {
39
64
  if (fields.org && /^[0-9a-f-]{36}$/i.test(fields.org))
40
65
  return `org=${fields.org}`;
41
- if (fields.tab === "users" || fields.tab === "settings")
66
+ if (fields.tab === "users" || fields.tab === "settings" || fields.tab === "roles")
42
67
  return `tab=${fields.tab}`;
43
68
  return "";
44
69
  }
45
- /** Roles the page offers in its dropdowns: whatever the yaml defines, plus any role already in use. */
70
+ /** Roles the page offers in its dropdowns: whatever is defined or saved, plus any role already in use. Owner first. */
46
71
  export function roleChoices(defined, inUse) {
47
- return [...new Set(["owner", "admin", "member", ...defined, ...inUse])];
72
+ return [...new Set([OWNER, "admin", "member", ...defined, ...inUse])];
73
+ }
74
+ /**
75
+ * The Roles grid: one checkbox per catalogue entry, named `p:<id>`. Which
76
+ * are on for a role follows from its effective grants, wildcards included
77
+ * ("all" ticks every box). Pure.
78
+ */
79
+ export function checkedFor(granted, catalogueIds) {
80
+ const s = { authorization: { role: null, permissions: [...granted] } };
81
+ return catalogueIds.filter((id) => hasPermission(s, id));
82
+ }
83
+ /**
84
+ * The grid posted back: the catalogue entries whose box was on, in
85
+ * catalogue order. Ids outside the catalogue are ignored, so a form can
86
+ * never grant something the app does not gate. Pure.
87
+ */
88
+ export function grantedFromFields(fields, catalogueIds) {
89
+ return catalogueIds.filter((id) => PERMISSION_ID.test(id) && !!fields[`p:${id}`]);
48
90
  }
@@ -7,9 +7,9 @@ export declare const COOKIE_NAME = "auth_session";
7
7
  export declare function cookieName(production: boolean): string;
8
8
  /** The default; b17_auth.yaml `sessionDays` overrides it */
9
9
  export declare const SESSION_DAYS = 7;
10
- export declare const SIGN_IN_PATH = "/auth/sign-in";
10
+ /** The built-in pages; b17_auth.yaml `pages:` can move them (core/pages.ts) */
11
+ export { SIGN_IN_PATH, SIGN_UP_PATH } from "./pages.ts";
11
12
  export declare const SIGN_OUT_PATH = "/auth/sign-out";
12
- export declare const SIGN_UP_PATH = "/auth/sign-up";
13
13
  export declare const TABLE = "users";
14
14
  export declare function getSecret(): string;
15
15
  export declare function getDatabaseUrl(): string;
@@ -20,9 +20,9 @@ export function cookieName(production) {
20
20
  }
21
21
  /** The default; b17_auth.yaml `sessionDays` overrides it */
22
22
  export const SESSION_DAYS = 7;
23
- export const SIGN_IN_PATH = "/auth/sign-in";
23
+ /** The built-in pages; b17_auth.yaml `pages:` can move them (core/pages.ts) */
24
+ export { SIGN_IN_PATH, SIGN_UP_PATH } from "./pages.js";
24
25
  export const SIGN_OUT_PATH = "/auth/sign-out";
25
- export const SIGN_UP_PATH = "/auth/sign-up";
26
26
  export const TABLE = "users";
27
27
  export function getSecret() {
28
28
  const secret = options().secret;
package/dist/core/db.d.ts CHANGED
@@ -72,15 +72,39 @@ export type UserState = {
72
72
  export declare function userState(id: string, store?: Db): Promise<UserState | null>;
73
73
  /** The user's current session_epoch, or null if the user is gone */
74
74
  export declare function sessionEpoch(id: string, store?: Db): Promise<number | null>;
75
- /** Everything a session needs, in one query: the user's state and their active organization. */
75
+ /** Everything a session needs, in one query: the user's state, their active organization, and what the admin page saved for their role. */
76
76
  export type SessionRow = UserState & {
77
77
  id: string;
78
78
  email: string;
79
79
  org_id: string | null;
80
80
  org_name: string | null;
81
81
  role: string | null;
82
+ /** permissions saved on /auth/admin for this role in this organization, or null */
83
+ stored_org?: string[] | null;
84
+ /** permissions saved on /auth/admin for this role in every organization, or null */
85
+ stored_all?: string[] | null;
82
86
  };
83
87
  export declare function sessionRow(id: string, store?: Db): Promise<SessionRow | null>;
88
+ export type StoredRole = {
89
+ scope: string;
90
+ name: string;
91
+ permissions: string[];
92
+ updated_at: string;
93
+ };
94
+ /** Every saved role, all scopes. */
95
+ export declare function storedRoles(store?: Db): Promise<StoredRole[]>;
96
+ /** Save what a role may do: for every organization (scope "") or one (its id). Replaces the previous list. */
97
+ export declare function setRolePermissions(scope: string, name: string, permissions: string[], store?: Db): Promise<void>;
98
+ /** Forget an organization's own rule for a role: it follows the rule for every organization again. False if there was none. */
99
+ export declare function resetRole(scope: string, name: string, store?: Db): Promise<boolean>;
100
+ /**
101
+ * The starting point, written once: every role from b17_auth.yaml `roles:`
102
+ * that has no row for every organization yet. A role the admin page has
103
+ * already saved is never touched. Returns the names written.
104
+ */
105
+ export declare function seedRoles(defaults: Record<string, string[]>, store?: Db): Promise<string[]>;
106
+ /** The user's role in one organization, or null when they are not a member. */
107
+ export declare function memberRole(userId: string, orgId: string, store?: Db): Promise<string | null>;
84
108
  export type OrgRow = {
85
109
  id: string;
86
110
  name: string;
package/dist/core/db.js CHANGED
@@ -146,6 +146,22 @@ export const MIGRATIONS = [
146
146
  `CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower ON users (LOWER(email))`,
147
147
  ],
148
148
  },
149
+ {
150
+ id: 8,
151
+ name: "roles",
152
+ statements: [
153
+ // What a role may do, as saved on /auth/admin. b17_auth.yaml is the
154
+ // starting point; a row here wins. scope '' = every organization,
155
+ // an organization's id = that organization only.
156
+ `CREATE TABLE IF NOT EXISTS roles (
157
+ scope TEXT NOT NULL DEFAULT '',
158
+ name TEXT NOT NULL,
159
+ permissions TEXT[] NOT NULL,
160
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
161
+ PRIMARY KEY (scope, name)
162
+ )`,
163
+ ],
164
+ },
149
165
  ];
150
166
  /**
151
167
  * The package's tables have plain names (users, orgs, members, tokens).
@@ -274,7 +290,8 @@ export async function sessionRow(id, store = db()) {
274
290
  if (!UUID.test(id))
275
291
  return null;
276
292
  await ensureSchema(store);
277
- const rows = await store.query(`SELECT u.id, u.email, u.session_epoch, u.email_verified_at, o.id AS org_id, o.name AS org_name, m.role
293
+ const rows = await store.query(`SELECT u.id, u.email, u.session_epoch, u.email_verified_at, o.id AS org_id, o.name AS org_name, m.role,
294
+ ro.permissions AS stored_org, ra.permissions AS stored_all
278
295
  FROM users u
279
296
  LEFT JOIN members m
280
297
  ON m.user_id = u.id
@@ -283,6 +300,8 @@ export async function sessionRow(id, store = db()) {
283
300
  (SELECT f.org_id FROM members f WHERE f.user_id = u.id ORDER BY f.created_at, f.org_id LIMIT 1)
284
301
  )
285
302
  LEFT JOIN orgs o ON o.id = m.org_id
303
+ LEFT JOIN roles ro ON ro.scope = o.id::text AND ro.name = m.role
304
+ LEFT JOIN roles ra ON ra.scope = '' AND ra.name = m.role
286
305
  WHERE u.id = $1`, [id]);
287
306
  const r = rows[0];
288
307
  if (!r)
@@ -295,8 +314,50 @@ export async function sessionRow(id, store = db()) {
295
314
  org_id: r.org_id,
296
315
  org_name: r.org_name,
297
316
  role: r.role,
317
+ stored_org: r.stored_org ?? null,
318
+ stored_all: r.stored_all ?? null,
298
319
  };
299
320
  }
321
+ /** Every saved role, all scopes. */
322
+ export async function storedRoles(store = db()) {
323
+ await ensureSchema(store);
324
+ return store.query(`SELECT scope, name, permissions, updated_at FROM roles ORDER BY scope, name`);
325
+ }
326
+ /** Save what a role may do: for every organization (scope "") or one (its id). Replaces the previous list. */
327
+ export async function setRolePermissions(scope, name, permissions, store = db()) {
328
+ await ensureSchema(store);
329
+ await store.query(`INSERT INTO roles (scope, name, permissions, updated_at) VALUES ($1, $2, $3, now())
330
+ ON CONFLICT (scope, name) DO UPDATE SET permissions = EXCLUDED.permissions, updated_at = now()`, [scope, name, permissions]);
331
+ }
332
+ /** Forget an organization's own rule for a role: it follows the rule for every organization again. False if there was none. */
333
+ export async function resetRole(scope, name, store = db()) {
334
+ await ensureSchema(store);
335
+ const rows = await store.query(`DELETE FROM roles WHERE scope = $1 AND name = $2 RETURNING name`, [scope, name]);
336
+ return rows.length > 0;
337
+ }
338
+ /**
339
+ * The starting point, written once: every role from b17_auth.yaml `roles:`
340
+ * that has no row for every organization yet. A role the admin page has
341
+ * already saved is never touched. Returns the names written.
342
+ */
343
+ export async function seedRoles(defaults, store = db()) {
344
+ await ensureSchema(store);
345
+ const written = [];
346
+ for (const [name, permissions] of Object.entries(defaults)) {
347
+ const rows = await store.query(`INSERT INTO roles (scope, name, permissions) VALUES ('', $1, $2) ON CONFLICT (scope, name) DO NOTHING RETURNING name`, [name, permissions]);
348
+ if (rows.length)
349
+ written.push(name);
350
+ }
351
+ return written;
352
+ }
353
+ /** The user's role in one organization, or null when they are not a member. */
354
+ export async function memberRole(userId, orgId, store = db()) {
355
+ if (!UUID.test(userId) || !UUID.test(orgId))
356
+ return null;
357
+ await ensureSchema(store);
358
+ const rows = await store.query(`SELECT role FROM members WHERE user_id = $1 AND org_id = $2`, [userId, orgId]);
359
+ return rows[0]?.role ?? null;
360
+ }
300
361
  export async function createOrg(name, store = db()) {
301
362
  await ensureSchema(store);
302
363
  const [row] = await store.query(`INSERT INTO orgs (name) VALUES ($1) RETURNING id, name, created_at`, [name.trim()]);
@@ -19,9 +19,12 @@ export declare function matchesPattern(pathname: string, pattern: string): boole
19
19
  export type AccessRules = {
20
20
  public?: readonly string[];
21
21
  protected?: readonly string[];
22
+ /** The app's own sign-in / sign-up pages (b17_auth.yaml `pages:`): exact paths, always open */
23
+ pages?: readonly string[];
22
24
  };
23
25
  /**
24
- * Paths reachable without a session. The auth pages and Next internals
26
+ * Paths reachable without a session. The auth pages (the package's under
27
+ * /auth/, and the app's own named under `pages:`) and Next internals
25
28
  * always are. Then one of two modes from b17_auth.yaml:
26
29
  *
27
30
  * protected: /app/* /api/* only these need a session; everything else is open