@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.
- package/AGENTS.md +29 -13
- package/CHANGELOG.md +18 -0
- package/PROMPT.md +4 -3
- package/README.md +78 -21
- package/dist/cli.js +22 -6
- package/dist/core/admin-html.d.ts +8 -2
- package/dist/core/admin-html.js +133 -35
- package/dist/core/admin.d.ts +35 -5
- package/dist/core/admin.js +49 -7
- package/dist/core/config.d.ts +2 -2
- package/dist/core/config.js +2 -2
- package/dist/core/db.d.ts +25 -1
- package/dist/core/db.js +62 -1
- package/dist/core/guard.d.ts +4 -1
- package/dist/core/guard.js +4 -1
- package/dist/core/html.d.ts +8 -1
- package/dist/core/html.js +26 -13
- package/dist/core/invite.d.ts +7 -0
- package/dist/core/invite.js +30 -0
- package/dist/core/options.d.ts +24 -5
- package/dist/core/options.js +89 -22
- package/dist/core/org.d.ts +14 -5
- package/dist/core/org.js +27 -17
- package/dist/core/pages.d.ts +34 -0
- package/dist/core/pages.js +73 -0
- package/dist/core/signup.d.ts +4 -0
- package/dist/core/signup.js +7 -3
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/next/index.js +250 -56
- package/dist/next/proxy.js +14 -6
- package/dist/react/index.d.ts +55 -1
- package/dist/react/index.js +54 -2
- package/package.json +3 -2
package/dist/core/admin-html.js
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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"
|
|
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">·
|
|
144
|
-
${
|
|
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
|
-
|
|
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">
|
|
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}"
|
|
197
|
-
<button type="submit" class="primary">
|
|
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
|
|
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(
|
|
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="
|
|
237
|
-
|
|
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.
|
|
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>`;
|
package/dist/core/admin.d.ts
CHANGED
|
@@ -1,13 +1,31 @@
|
|
|
1
|
-
import type
|
|
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
|
-
|
|
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
|
|
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
|
|
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[];
|
package/dist/core/admin.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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([
|
|
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
|
}
|
package/dist/core/config.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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()]);
|
package/dist/core/guard.d.ts
CHANGED
|
@@ -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
|
|
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
|