@binary1702/auth 0.2.5 → 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.
Files changed (53) hide show
  1. package/AGENTS.md +155 -0
  2. package/CHANGELOG.md +49 -0
  3. package/INSTALL.md +27 -16
  4. package/PROMPT.md +325 -36
  5. package/README.md +338 -53
  6. package/dist/cli.js +248 -17
  7. package/dist/core/admin-html.d.ts +32 -0
  8. package/dist/core/admin-html.js +350 -0
  9. package/dist/core/admin.d.ts +56 -0
  10. package/dist/core/admin.js +90 -0
  11. package/dist/core/config.d.ts +10 -3
  12. package/dist/core/config.js +22 -8
  13. package/dist/core/db.d.ts +155 -10
  14. package/dist/core/db.js +449 -69
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +52 -0
  18. package/dist/core/guard.js +106 -0
  19. package/dist/core/html.d.ts +43 -0
  20. package/dist/core/html.js +122 -28
  21. package/dist/core/invite.d.ts +7 -0
  22. package/dist/core/invite.js +30 -0
  23. package/dist/core/options.d.ts +95 -0
  24. package/dist/core/options.js +377 -0
  25. package/dist/core/org.d.ts +43 -0
  26. package/dist/core/org.js +65 -0
  27. package/dist/core/pages.d.ts +34 -0
  28. package/dist/core/pages.js +73 -0
  29. package/dist/core/password.d.ts +16 -0
  30. package/dist/core/password.js +41 -12
  31. package/dist/core/rate.d.ts +26 -0
  32. package/dist/core/rate.js +70 -0
  33. package/dist/core/reset.d.ts +9 -0
  34. package/dist/core/reset.js +31 -0
  35. package/dist/core/session.d.ts +11 -1
  36. package/dist/core/session.js +27 -7
  37. package/dist/core/signup.d.ts +13 -6
  38. package/dist/core/signup.js +30 -15
  39. package/dist/core/tokens.d.ts +29 -0
  40. package/dist/core/tokens.js +64 -0
  41. package/dist/core/verify.d.ts +8 -0
  42. package/dist/core/verify.js +33 -0
  43. package/dist/core/yaml.d.ts +10 -0
  44. package/dist/core/yaml.js +85 -0
  45. package/dist/index.d.ts +18 -6
  46. package/dist/index.js +18 -6
  47. package/dist/next/index.d.ts +10 -3
  48. package/dist/next/index.js +649 -63
  49. package/dist/next/proxy.d.ts +2 -1
  50. package/dist/next/proxy.js +22 -10
  51. package/dist/react/index.d.ts +72 -0
  52. package/dist/react/index.js +107 -0
  53. package/package.json +18 -4
@@ -0,0 +1,350 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // The admin page. Plain HTML like the other auth pages.
3
+ //
4
+ // ?tab=orgs (default) organizations: name, members, roles in use → click one
5
+ // ?org=<id> one organization: its members, and its own rule per
6
+ // role (a grid of checkboxes; unset = follows Roles)
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)
11
+ // ?tab=settings the resolved settings, read-only
12
+ //
13
+ // An organization's owner sees only their organization (`limited`), and
14
+ // only its roles grid and members.
15
+ //
16
+ // Every change is a small form that POSTs back and returns to the same
17
+ // view. Nothing here decides anything; the handler and core/admin.ts
18
+ // do. Every value goes through escape().
19
+ // ─────────────────────────────────────────────────────────────
20
+ import { escape } from "./html.js";
21
+ import { ADMIN_PATH, checkedFor } from "./admin.js";
22
+ import { SIGN_OUT_PATH } from "./config.js";
23
+ import { landing } from "./pages.js";
24
+ import { PASSWORD_MAX, PASSWORD_MIN } from "./signup.js";
25
+ import { OWNER, permissionsFor } from "./org.js";
26
+ const STYLE = `
27
+ :root { color-scheme: light dark; --fg: #111; --bg: #fff; --line: #e3e3e3; --muted: #6b6b6b; --err: #c0392b; --ok: #1e8449; --panel: #f6f6f7; --accent: #2f7fd6; }
28
+ @media (prefers-color-scheme: dark) { :root { --fg: #ececec; --bg: #121212; --line: #2c2c2c; --muted: #9a9a9a; --err: #ff6b6b; --ok: #58d68d; --panel: #1c1c1e; --accent: #6fb1ff; } }
29
+ * { box-sizing: border-box; }
30
+ body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, -apple-system, sans-serif; }
31
+ main { max-width: 960px; margin: 0 auto; padding: 24px 20px 60px; }
32
+ h1 { font-size: 18px; margin: 0; }
33
+ h2 { font-size: 15px; margin: 0 0 10px; }
34
+ a { color: var(--accent); text-decoration: none; }
35
+ a:hover { text-decoration: underline; }
36
+ .top { display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; margin-bottom: 14px; }
37
+ .top .who { color: var(--muted); font-size: 13px; margin-left: auto; }
38
+ .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 18px; }
39
+ .tabs a { padding: 8px 12px; color: var(--muted); border-bottom: 2px solid transparent; font-weight: 500; }
40
+ .tabs a.on { color: var(--fg); border-bottom-color: var(--fg); }
41
+ .tabs a:hover { text-decoration: none; color: var(--fg); }
42
+ .crumb { font-size: 13px; color: var(--muted); margin-bottom: 10px; }
43
+ .notice, .error { padding: 8px 12px; border-radius: 8px; font-size: 13px; margin: 0 0 14px; }
44
+ .notice { background: color-mix(in srgb, var(--ok) 12%, var(--bg)); color: var(--ok); }
45
+ .error { background: color-mix(in srgb, var(--err) 12%, var(--bg)); color: var(--err); }
46
+ .card { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
47
+ .card.panel { background: var(--panel); }
48
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
49
+ th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 10px 8px 0; }
50
+ td { padding: 9px 10px 9px 0; border-top: 1px solid var(--line); vertical-align: middle; }
51
+ tr.row:hover td { background: color-mix(in srgb, var(--panel) 60%, var(--bg)); }
52
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
53
+ .muted { color: var(--muted); }
54
+ .small { font-size: 12px; }
55
+ .ok { color: var(--ok); }
56
+ .warn { color: var(--err); }
57
+ .right { text-align: right; white-space: nowrap; }
58
+ .center { text-align: center; }
59
+ form.inline { display: inline-flex; gap: 4px; align-items: center; margin: 0 4px 0 0; }
60
+ form.grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); align-items: end; }
61
+ label { display: grid; gap: 3px; font-size: 12px; color: var(--muted); }
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; }
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; }
66
+ button.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); }
67
+ button.danger { color: var(--err); border-color: var(--err); }
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; }
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); }
73
+ .chip.all { color: var(--fg); border-color: var(--fg); }
74
+ .pill { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); }
75
+ details summary { cursor: pointer; color: var(--muted); font-size: 13px; }
76
+ details.inline { display: inline-block; vertical-align: middle; margin-right: 6px; }
77
+ details.inline summary { display: inline-block; font-size: 12.5px; padding: 5px 10px; border: 1px solid transparent; border-radius: 6px; list-style: none; }
78
+ details.inline summary::-webkit-details-marker { display: none; }
79
+ details.inline[open] summary { color: var(--fg); }
80
+ details.inline form { margin-left: 4px; }
81
+ .nowrap { white-space: nowrap; }
82
+ button.quiet.danger { color: var(--err); }
83
+ pre { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font-size: 12px; overflow: auto; margin: 8px 0 0; }
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; }
87
+ `;
88
+ const hidden = (fields) => Object.entries(fields)
89
+ .map(([k, v]) => `<input type="hidden" name="${escape(k)}" value="${escape(v)}">`)
90
+ .join("");
91
+ const roleSelect = (name, roles, current) => `<select name="${name}">${roles.map((r) => `<option value="${escape(r)}"${r === current ? " selected" : ""}>${escape(r)}</option>`).join("")}</select>`;
92
+ // The CSP allows no inline handlers; a nonce'd script below wires these up
93
+ const confirmRemove = (what) => `data-confirm="Remove ${escape(what)}? This cannot be undone."`;
94
+ const chips = (perms) => perms.length ? perms.map((p) => (p === "*" ? `<span class="chip all">everything</span>` : `<span class="chip">${escape(p)}</span>`)).join("") : `<span class="chip">nothing</span>`;
95
+ export function adminPage(d) {
96
+ const usersPath = `${ADMIN_PATH}/users`;
97
+ const orgsPath = `${ADMIN_PATH}/orgs`;
98
+ const rolesPath = `${ADMIN_PATH}/roles`;
99
+ const o = d.options;
100
+ const tab = d.view.tab === "org" ? "orgs" : d.view.tab;
101
+ const link = (t) => `${ADMIN_PATH}${t === "orgs" ? "" : `?tab=${t}`}`;
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>`;
167
+ let body = "";
168
+ // ── organizations ───────────────────────────────────────────
169
+ if (d.view.tab === "orgs") {
170
+ const rows = d.orgs
171
+ .map((org) => {
172
+ const roles = [...new Set(org.members.map((m) => m.role))];
173
+ return `<tr class="row">
174
+ <td><a href="${orgLink(org.id)}"><b>${escape(org.name)}</b></a><br><code class="muted">${escape(org.id)}</code></td>
175
+ <td>${org.members.length} member${org.members.length === 1 ? "" : "s"}</td>
176
+ <td>${roles.length ? roles.map((r) => `<span class="pill">${escape(r)}</span>`).join(" ") : '<span class="muted">none yet</span>'}</td>
177
+ <td class="right"><a href="${orgLink(org.id)}">open →</a></td>
178
+ </tr>`;
179
+ })
180
+ .join("\n");
181
+ body = `
182
+ <div class="card">
183
+ <h2>Organizations <span class="muted">(${d.orgs.length})</span></h2>
184
+ ${d.orgs.length ? `<table><thead><tr><th>organization</th><th>members</th><th>roles in use</th><th></th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="empty">No organizations yet. Create the first one below.</p>'}
185
+ </div>
186
+ <div class="card panel">
187
+ <form class="grid" method="post" action="${orgsPath}">${hidden({ action: "create" })}
188
+ <label>New organization <input name="name" required autocomplete="off" placeholder="name"></label>
189
+ <button type="submit" class="primary">Create</button>
190
+ </form>
191
+ </div>`;
192
+ }
193
+ // ── one organization ────────────────────────────────────────
194
+ if (d.view.tab === "org") {
195
+ const orgId = d.view.orgId;
196
+ const org = d.orgs.find((x) => x.id === orgId);
197
+ if (!org) {
198
+ body = `<p class="error">No such organization.</p>${d.limited ? "" : `<p><a href="${ADMIN_PATH}">← Organizations</a></p>`}`;
199
+ }
200
+ else {
201
+ const inUse = org.members.map((m) => m.role);
202
+ const memberRows = org.members
203
+ .map((m) => `<tr class="row">
204
+ <td><b>${escape(m.email)}</b>${m.email === d.self ? ' <span class="muted">(you)</span>' : ""}</td>
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>
206
+ <td>${chips(effective(m.role, org.id))}</td>
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>
208
+ </tr>`)
209
+ .join("\n");
210
+ body = `
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>
212
+ <div class="card">
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}
215
+ </div>
216
+ <div class="card">
217
+ <h2>Members <span class="muted">(${org.members.length})</span></h2>
218
+ ${memberRows ? `<table><thead><tr><th>user</th><th>role</th><th>permissions here</th><th></th></tr></thead><tbody>${memberRows}</tbody></table>` : '<p class="empty">No members yet.</p>'}
219
+ </div>
220
+ <div class="card panel">
221
+ <form class="grid" method="post" action="${orgsPath}">${hidden({ action: "add-member", org: org.id })}
222
+ <label>Add a member <input type="email" name="email" placeholder="email of an existing user" required></label>
223
+ <label>as ${roleSelect("role", d.roles, "member")}</label>
224
+ <button type="submit" class="primary">Add</button>
225
+ </form>
226
+ </div>
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>`}`;
228
+ }
229
+ }
230
+ // ── users ───────────────────────────────────────────────────
231
+ if (d.view.tab === "users") {
232
+ const rows = d.users
233
+ .map((u) => {
234
+ const self = u.email === d.self;
235
+ const memberships = u.memberships.length
236
+ ? u.memberships.map((m) => `<a href="${orgLink(m.org_id)}">${escape(m.org)}</a> <span class="muted">(${escape(m.role)})</span>`).join(" · ")
237
+ : '<span class="muted">none</span>';
238
+ const status = u.verified
239
+ ? '<span class="ok">verified</span>'
240
+ : `<span class="warn">unverified</span>
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>
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>`;
244
+ return `<tr class="row">
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>
246
+ <td class="nowrap">${status}</td>
247
+ <td>${memberships}</td>
248
+ <td class="right">
249
+ <details class="inline"><summary class="quiet">password</summary><form class="inline" method="post" action="${usersPath}">${hidden({ action: "set-password", email: u.email, tab: "users" })}<input type="password" name="password" placeholder="new password" minlength="${PASSWORD_MIN}" maxlength="${PASSWORD_MAX}" required autocomplete="new-password" size="14"><button type="submit">set</button></form></details>
250
+ ${self
251
+ ? ""
252
+ : `<form class="inline" method="post" action="${usersPath}">${hidden({ action: "sign-out", email: u.email, tab: "users" })}<button type="submit" class="quiet">sign out</button></form>
253
+ <form class="inline" method="post" action="${usersPath}" ${confirmRemove(u.email)}>${hidden({ action: "remove", email: u.email, tab: "users" })}<button type="submit" class="quiet danger">remove</button></form>`}
254
+ </td>
255
+ </tr>`;
256
+ })
257
+ .join("\n");
258
+ body = `
259
+ <div class="card">
260
+ <h2>Users <span class="muted">(${d.users.length})</span></h2>
261
+ <table><thead><tr><th>user</th><th>email</th><th>organizations</th><th></th></tr></thead><tbody>${rows}</tbody></table>
262
+ <p class="empty">What a role may do is under Roles, or inside an organization for its own rule.</p>
263
+ </div>
264
+ <div class="card panel">
265
+ <form class="grid" method="post" action="${usersPath}">${hidden({ action: "add", tab: "users" })}
266
+ <label>New user <input type="email" name="email" required autocomplete="off" placeholder="email"></label>
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>
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>
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>`;
283
+ }
284
+ // ── settings ────────────────────────────────────────────────
285
+ if (d.view.tab === "settings") {
286
+ const catalogueRows = catalogue.map(([id, label]) => ` ${id}: ${label}`).join("\n");
287
+ const defaultRows = Object.entries(o.roles)
288
+ .map(([role, perms]) => ` ${role}: ${perms.join(" ") || "(nothing)"}`)
289
+ .join("\n");
290
+ body = `
291
+ <div class="card">
292
+ <h2>Settings <span class="muted">· read-only, from b17_auth.yaml and .env.local</span></h2>
293
+ <table><tbody>
294
+ <tr><td>name</td><td>${escape(o.name || "(none: the request's host)")}</td></tr>
295
+ <tr><td>signup</td><td>${escape(o.signup)}${o.emailDomain ? ` · only @${escape(o.emailDomain)}` : ""}</td></tr>
296
+ <tr><td>requireVerified</td><td>${o.requireVerified}</td></tr>
297
+ <tr><td>sessionDays</td><td>${o.sessionDays}</td></tr>
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>
300
+ <tr><td>admins</td><td>${escape(o.admins.join(" "))}</td></tr>
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>
302
+ <tr><td>tokens</td><td>${d.tokens.total} issued · ${d.tokens.live} still usable</td></tr>
303
+ </tbody></table>
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>
309
+ </div>`;
310
+ }
311
+ return `<!doctype html>
312
+ <html lang="en">
313
+ <head>
314
+ <meta charset="utf-8">
315
+ <meta name="viewport" content="width=device-width, initial-scale=1">
316
+ <title>Admin${o.name ? ` · ${escape(o.name)}` : ""}</title>
317
+ <style>${STYLE}</style>
318
+ </head>
319
+ <body>
320
+ <main>
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">
325
+ <a href="${link("orgs")}" class="${tab === "orgs" ? "on" : ""}">Organizations</a>
326
+ <a href="${link("users")}" class="${tab === "users" ? "on" : ""}">Users</a>
327
+ <a href="${link("roles")}" class="${tab === "roles" ? "on" : ""}">Roles</a>
328
+ <a href="${link("settings")}" class="${tab === "settings" ? "on" : ""}">Settings</a>
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>` : ""}
338
+ ${body}
339
+ </main>
340
+ <script nonce="${escape(d.nonce)}">
341
+ for (const f of document.querySelectorAll("form[data-confirm]")) {
342
+ f.addEventListener("submit", (e) => { if (!confirm(f.dataset.confirm)) e.preventDefault() })
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
+ }
347
+ </script>
348
+ </body>
349
+ </html>`;
350
+ }
@@ -0,0 +1,56 @@
1
+ import { type Session } from "./org.ts";
2
+ export declare const ADMIN_PATH = "/auth/admin";
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;
11
+ /** The forms on the page. Each is one POST with `action` and a few fields. */
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"];
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"];
21
+ export type UserAction = (typeof USER_ACTIONS)[number];
22
+ export type OrgAction = (typeof ORG_ACTIONS)[number];
23
+ export type RoleAction = (typeof ROLE_ACTIONS)[number];
24
+ export declare const isUserAction: (a: string) => a is UserAction;
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;
29
+ /**
30
+ * What an admin may not do to themselves: remove or lock out the account
31
+ * they are using. (Their own password change is fine: it ends their other
32
+ * sessions, and the page signs them in again.)
33
+ */
34
+ export declare function refusedOnSelf(action: UserAction, targetEmail: string, selfEmail: string): string | null;
35
+ /**
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.
38
+ */
39
+ export declare function backTo(fields: {
40
+ org?: string;
41
+ tab?: string;
42
+ }): string;
43
+ /** Roles the page offers in its dropdowns: whatever is defined or saved, plus any role already in use. Owner first. */
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[];
@@ -0,0 +1,90 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // The admin page's rules. Pure.
3
+ //
4
+ // admins: ben@example.com … (b17_auth.yaml, or AUTH_ADMINS) may open
5
+ // /auth/admin and do what the CLI does:
6
+ // users, memberships, and what each role
7
+ // may do (saved in the database; the yaml
8
+ // is the starting point). Never the yaml.
9
+ //
10
+ // An admin is an email, not a role: a role lives in one organization,
11
+ // the admin page spans all of them.
12
+ // ─────────────────────────────────────────────────────────────
13
+ import { OWNER, hasPermission } from "./org.js";
14
+ import { PERMISSION_ID } from "./options.js";
15
+ export const ADMIN_PATH = "/auth/admin";
16
+ export function isAdmin(session, admins) {
17
+ return !!session && admins.includes(session.user.email.toLowerCase());
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
+ }
30
+ /** The forms on the page. Each is one POST with `action` and a few fields. */
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"];
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"];
40
+ export const isUserAction = (a) => USER_ACTIONS.includes(a);
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;
45
+ /**
46
+ * What an admin may not do to themselves: remove or lock out the account
47
+ * they are using. (Their own password change is fine: it ends their other
48
+ * sessions, and the page signs them in again.)
49
+ */
50
+ export function refusedOnSelf(action, targetEmail, selfEmail) {
51
+ if (targetEmail.toLowerCase() !== selfEmail.toLowerCase())
52
+ return null;
53
+ if (action === "remove")
54
+ return "You cannot remove the account you are signed in with.";
55
+ if (action === "sign-out")
56
+ return "Use /auth/sign-out to end your own session.";
57
+ return null;
58
+ }
59
+ /**
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.
62
+ */
63
+ export function backTo(fields) {
64
+ if (fields.org && /^[0-9a-f-]{36}$/i.test(fields.org))
65
+ return `org=${fields.org}`;
66
+ if (fields.tab === "users" || fields.tab === "settings" || fields.tab === "roles")
67
+ return `tab=${fields.tab}`;
68
+ return "";
69
+ }
70
+ /** Roles the page offers in its dropdowns: whatever is defined or saved, plus any role already in use. Owner first. */
71
+ export function roleChoices(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}`]);
90
+ }
@@ -1,8 +1,15 @@
1
1
  export declare const COOKIE_NAME = "auth_session";
2
+ /**
3
+ * In production the cookie carries the __Host- prefix: the browser then
4
+ * refuses it unless it is Secure, has Path=/ and no Domain, so a
5
+ * subdomain or a plain-http page cannot plant one. Pure.
6
+ */
7
+ export declare function cookieName(production: boolean): string;
8
+ /** The default; b17_auth.yaml `sessionDays` overrides it */
2
9
  export declare const SESSION_DAYS = 7;
3
- 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";
4
12
  export declare const SIGN_OUT_PATH = "/auth/sign-out";
5
- export declare const SIGN_UP_PATH = "/auth/sign-up";
6
- export declare const TABLE = "auth_users";
13
+ export declare const TABLE = "users";
7
14
  export declare function getSecret(): string;
8
15
  export declare function getDatabaseUrl(): string;
@@ -1,24 +1,38 @@
1
1
  // ─────────────────────────────────────────────────────────────
2
- // Configuration. Two env vars, nothing else.
2
+ // Constants, and the two values nothing works without.
3
3
  //
4
- // DATABASE_URL Postgres connection string (Neon)
5
- // AUTH_SECRET signs the session cookie; generated by the CLI
4
+ // Everything configurable comes from core/options.ts: b17_auth.yaml
5
+ // with ${NAME} references, or the env vars on their own:
6
+ //
7
+ // DATABASE_URL Postgres connection string (Neon) required
8
+ // AUTH_SECRET signs the session cookie generated by the CLI
9
+ // the rest see core/options.ts
6
10
  // ─────────────────────────────────────────────────────────────
11
+ import { options } from "./options.js";
7
12
  export const COOKIE_NAME = "auth_session";
13
+ /**
14
+ * In production the cookie carries the __Host- prefix: the browser then
15
+ * refuses it unless it is Secure, has Path=/ and no Domain, so a
16
+ * subdomain or a plain-http page cannot plant one. Pure.
17
+ */
18
+ export function cookieName(production) {
19
+ return production ? `__Host-${COOKIE_NAME}` : COOKIE_NAME;
20
+ }
21
+ /** The default; b17_auth.yaml `sessionDays` overrides it */
8
22
  export const SESSION_DAYS = 7;
9
- 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";
10
25
  export const SIGN_OUT_PATH = "/auth/sign-out";
11
- export const SIGN_UP_PATH = "/auth/sign-up";
12
- export const TABLE = "auth_users";
26
+ export const TABLE = "users";
13
27
  export function getSecret() {
14
- const secret = process.env.AUTH_SECRET;
28
+ const secret = options().secret;
15
29
  if (!secret || secret.length < 16) {
16
30
  throw new Error("AUTH_SECRET is missing. Run: npx @binary1702/auth");
17
31
  }
18
32
  return secret;
19
33
  }
20
34
  export function getDatabaseUrl() {
21
- const url = process.env.DATABASE_URL;
35
+ const url = options().database;
22
36
  if (!url) {
23
37
  throw new Error("DATABASE_URL is missing. Run: npx @binary1702/auth");
24
38
  }