@binary1702/auth 0.2.4 → 0.3.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 +139 -0
- package/CHANGELOG.md +31 -0
- package/INSTALL.md +30 -17
- package/PROMPT.md +325 -37
- package/README.md +293 -56
- package/dist/cli.js +255 -22
- package/dist/core/admin-html.d.ts +26 -0
- package/dist/core/admin-html.js +252 -0
- package/dist/core/admin.d.ts +26 -0
- package/dist/core/admin.js +48 -0
- package/dist/core/config.d.ts +8 -1
- package/dist/core/config.js +20 -6
- package/dist/core/db.d.ts +138 -7
- package/dist/core/db.js +406 -47
- package/dist/core/email.d.ts +49 -0
- package/dist/core/email.js +213 -0
- package/dist/core/guard.d.ts +49 -0
- package/dist/core/guard.js +103 -0
- package/dist/core/html.d.ts +36 -0
- package/dist/core/html.js +106 -25
- package/dist/core/options.d.ts +76 -0
- package/dist/core/options.js +310 -0
- package/dist/core/org.d.ts +34 -0
- package/dist/core/org.js +55 -0
- package/dist/core/password.d.ts +16 -0
- package/dist/core/password.js +41 -12
- package/dist/core/rate.d.ts +26 -0
- package/dist/core/rate.js +70 -0
- package/dist/core/reset.d.ts +9 -0
- package/dist/core/reset.js +31 -0
- package/dist/core/session.d.ts +23 -2
- package/dist/core/session.js +45 -7
- package/dist/core/signup.d.ts +9 -6
- package/dist/core/signup.js +26 -15
- package/dist/core/tokens.d.ts +29 -0
- package/dist/core/tokens.js +64 -0
- package/dist/core/verify.d.ts +8 -0
- package/dist/core/verify.js +33 -0
- package/dist/core/yaml.d.ts +10 -0
- package/dist/core/yaml.js +85 -0
- package/dist/index.d.ts +17 -6
- package/dist/index.js +17 -6
- package/dist/next/index.d.ts +11 -4
- package/dist/next/index.js +455 -51
- package/dist/next/proxy.d.ts +2 -1
- package/dist/next/proxy.js +16 -7
- package/dist/react/index.d.ts +18 -0
- package/dist/react/index.js +55 -0
- package/package.json +16 -3
|
@@ -0,0 +1,252 @@
|
|
|
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 roles → permissions, its members
|
|
6
|
+
// ?tab=users every user: verification, memberships, actions
|
|
7
|
+
// ?tab=settings the resolved settings, read-only
|
|
8
|
+
//
|
|
9
|
+
// Every change is a small form that POSTs back and returns to the same
|
|
10
|
+
// view. Nothing here decides anything; the handler and core/admin.ts
|
|
11
|
+
// do. Every value goes through escape().
|
|
12
|
+
// ─────────────────────────────────────────────────────────────
|
|
13
|
+
import { escape } from "./html.js";
|
|
14
|
+
import { ADMIN_PATH } from "./admin.js";
|
|
15
|
+
import { SIGN_OUT_PATH } from "./config.js";
|
|
16
|
+
import { PASSWORD_MAX, PASSWORD_MIN } from "./signup.js";
|
|
17
|
+
import { permissionsFor } from "./org.js";
|
|
18
|
+
const STYLE = `
|
|
19
|
+
:root { color-scheme: light dark; --fg: #111; --bg: #fff; --line: #e3e3e3; --muted: #6b6b6b; --err: #c0392b; --ok: #1e8449; --panel: #f6f6f7; --accent: #2f7fd6; }
|
|
20
|
+
@media (prefers-color-scheme: dark) { :root { --fg: #ececec; --bg: #121212; --line: #2c2c2c; --muted: #9a9a9a; --err: #ff6b6b; --ok: #58d68d; --panel: #1c1c1e; --accent: #6fb1ff; } }
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.45 system-ui, -apple-system, sans-serif; }
|
|
23
|
+
main { max-width: 960px; margin: 0 auto; padding: 24px 20px 60px; }
|
|
24
|
+
h1 { font-size: 18px; margin: 0; }
|
|
25
|
+
h2 { font-size: 15px; margin: 0 0 10px; }
|
|
26
|
+
a { color: var(--accent); text-decoration: none; }
|
|
27
|
+
a:hover { text-decoration: underline; }
|
|
28
|
+
.top { display: flex; gap: 14px; align-items: baseline; flex-wrap: wrap; margin-bottom: 14px; }
|
|
29
|
+
.top .who { color: var(--muted); font-size: 13px; margin-left: auto; }
|
|
30
|
+
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line); margin-bottom: 18px; }
|
|
31
|
+
.tabs a { padding: 8px 12px; color: var(--muted); border-bottom: 2px solid transparent; font-weight: 500; }
|
|
32
|
+
.tabs a.on { color: var(--fg); border-bottom-color: var(--fg); }
|
|
33
|
+
.tabs a:hover { text-decoration: none; color: var(--fg); }
|
|
34
|
+
.crumb { font-size: 13px; color: var(--muted); margin-bottom: 10px; }
|
|
35
|
+
.notice, .error { padding: 8px 12px; border-radius: 8px; font-size: 13px; margin: 0 0 14px; }
|
|
36
|
+
.notice { background: color-mix(in srgb, var(--ok) 12%, var(--bg)); color: var(--ok); }
|
|
37
|
+
.error { background: color-mix(in srgb, var(--err) 12%, var(--bg)); color: var(--err); }
|
|
38
|
+
.card { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; }
|
|
39
|
+
.card.panel { background: var(--panel); }
|
|
40
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
41
|
+
th { text-align: left; color: var(--muted); font-weight: 500; padding: 4px 10px 8px 0; }
|
|
42
|
+
td { padding: 9px 10px 9px 0; border-top: 1px solid var(--line); vertical-align: middle; }
|
|
43
|
+
tr.row:hover td { background: color-mix(in srgb, var(--panel) 60%, var(--bg)); }
|
|
44
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
|
45
|
+
.muted { color: var(--muted); }
|
|
46
|
+
.ok { color: var(--ok); }
|
|
47
|
+
.warn { color: var(--err); }
|
|
48
|
+
.right { text-align: right; white-space: nowrap; }
|
|
49
|
+
form.inline { display: inline-flex; gap: 4px; align-items: center; margin: 0 4px 0 0; }
|
|
50
|
+
form.grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); align-items: end; }
|
|
51
|
+
label { display: grid; gap: 3px; font-size: 12px; color: var(--muted); }
|
|
52
|
+
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; }
|
|
53
|
+
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
|
+
button.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); }
|
|
55
|
+
button.danger { color: var(--err); border-color: var(--err); }
|
|
56
|
+
button.quiet { border-color: transparent; color: var(--muted); }
|
|
57
|
+
.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
|
+
.chip.all { color: var(--fg); border-color: var(--fg); }
|
|
59
|
+
.pill { display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--line); background: var(--panel); }
|
|
60
|
+
details summary { cursor: pointer; color: var(--muted); font-size: 13px; }
|
|
61
|
+
details.inline { display: inline-block; vertical-align: middle; margin-right: 6px; }
|
|
62
|
+
details.inline summary { display: inline-block; font-size: 12.5px; padding: 5px 10px; border: 1px solid transparent; border-radius: 6px; list-style: none; }
|
|
63
|
+
details.inline summary::-webkit-details-marker { display: none; }
|
|
64
|
+
details.inline[open] summary { color: var(--fg); }
|
|
65
|
+
details.inline form { margin-left: 4px; }
|
|
66
|
+
.nowrap { white-space: nowrap; }
|
|
67
|
+
button.quiet.danger { color: var(--err); }
|
|
68
|
+
pre { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 10px; font-size: 12px; overflow: auto; margin: 8px 0 0; }
|
|
69
|
+
.empty { color: var(--muted); font-size: 13px; padding: 6px 0; }
|
|
70
|
+
`;
|
|
71
|
+
const hidden = (fields) => Object.entries(fields)
|
|
72
|
+
.map(([k, v]) => `<input type="hidden" name="${escape(k)}" value="${escape(v)}">`)
|
|
73
|
+
.join("");
|
|
74
|
+
const roleSelect = (name, roles, current) => `<select name="${name}">${roles.map((r) => `<option value="${escape(r)}"${r === current ? " selected" : ""}>${escape(r)}</option>`).join("")}</select>`;
|
|
75
|
+
// The CSP allows no inline handlers; a nonce'd script below wires these up
|
|
76
|
+
const confirmRemove = (what) => `data-confirm="Remove ${escape(what)}? This cannot be undone."`;
|
|
77
|
+
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>`;
|
|
78
|
+
export function adminPage(d) {
|
|
79
|
+
const usersPath = `${ADMIN_PATH}/users`;
|
|
80
|
+
const orgsPath = `${ADMIN_PATH}/orgs`;
|
|
81
|
+
const o = d.options;
|
|
82
|
+
const tab = d.view.tab === "org" ? "orgs" : d.view.tab;
|
|
83
|
+
const link = (t) => `${ADMIN_PATH}${t === "orgs" ? "" : `?tab=${t}`}`;
|
|
84
|
+
const orgLink = (id) => `${ADMIN_PATH}?org=${escape(id)}`;
|
|
85
|
+
let body = "";
|
|
86
|
+
// ── organizations ───────────────────────────────────────────
|
|
87
|
+
if (d.view.tab === "orgs") {
|
|
88
|
+
const rows = d.orgs
|
|
89
|
+
.map((org) => {
|
|
90
|
+
const roles = [...new Set(org.members.map((m) => m.role))];
|
|
91
|
+
return `<tr class="row">
|
|
92
|
+
<td><a href="${orgLink(org.id)}"><b>${escape(org.name)}</b></a><br><code class="muted">${escape(org.id)}</code></td>
|
|
93
|
+
<td>${org.members.length} member${org.members.length === 1 ? "" : "s"}</td>
|
|
94
|
+
<td>${roles.length ? roles.map((r) => `<span class="pill">${escape(r)}</span>`).join(" ") : '<span class="muted">none yet</span>'}</td>
|
|
95
|
+
<td class="right"><a href="${orgLink(org.id)}">open →</a></td>
|
|
96
|
+
</tr>`;
|
|
97
|
+
})
|
|
98
|
+
.join("\n");
|
|
99
|
+
body = `
|
|
100
|
+
<div class="card">
|
|
101
|
+
<h2>Organizations <span class="muted">(${d.orgs.length})</span></h2>
|
|
102
|
+
${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>'}
|
|
103
|
+
</div>
|
|
104
|
+
<div class="card panel">
|
|
105
|
+
<form class="grid" method="post" action="${orgsPath}">${hidden({ action: "create" })}
|
|
106
|
+
<label>New organization <input name="name" required autocomplete="off" placeholder="name"></label>
|
|
107
|
+
<button type="submit" class="primary">Create</button>
|
|
108
|
+
</form>
|
|
109
|
+
</div>`;
|
|
110
|
+
}
|
|
111
|
+
// ── one organization ────────────────────────────────────────
|
|
112
|
+
if (d.view.tab === "org") {
|
|
113
|
+
const orgId = d.view.orgId;
|
|
114
|
+
const org = d.orgs.find((x) => x.id === orgId);
|
|
115
|
+
if (!org) {
|
|
116
|
+
body = `<p class="error">No such organization.</p><p><a href="${ADMIN_PATH}">← Organizations</a></p>`;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
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
|
+
const memberRows = org.members
|
|
133
|
+
.map((m) => `<tr class="row">
|
|
134
|
+
<td><b>${escape(m.email)}</b>${m.email === d.self ? ' <span class="muted">(you)</span>' : ""}</td>
|
|
135
|
+
<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>
|
|
137
|
+
<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
|
+
</tr>`)
|
|
139
|
+
.join("\n");
|
|
140
|
+
body = `
|
|
141
|
+
<div class="crumb"><a href="${ADMIN_PATH}">Organizations</a> › <b>${escape(org.name)}</b> <code class="muted">${escape(org.id)}</code></div>
|
|
142
|
+
<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>'}
|
|
145
|
+
</div>
|
|
146
|
+
<div class="card">
|
|
147
|
+
<h2>Members <span class="muted">(${org.members.length})</span></h2>
|
|
148
|
+
${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>'}
|
|
149
|
+
</div>
|
|
150
|
+
<div class="card panel">
|
|
151
|
+
<form class="grid" method="post" action="${orgsPath}">${hidden({ action: "add-member", org: org.id })}
|
|
152
|
+
<label>Add a member <input type="email" name="email" placeholder="email of an existing user" required></label>
|
|
153
|
+
<label>as ${roleSelect("role", d.roles, "member")}</label>
|
|
154
|
+
<button type="submit" class="primary">Add</button>
|
|
155
|
+
</form>
|
|
156
|
+
</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>`;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// ── users ───────────────────────────────────────────────────
|
|
161
|
+
if (d.view.tab === "users") {
|
|
162
|
+
const rows = d.users
|
|
163
|
+
.map((u) => {
|
|
164
|
+
const self = u.email === d.self;
|
|
165
|
+
const memberships = u.memberships.length
|
|
166
|
+
? u.memberships.map((m) => `<a href="${orgLink(m.org_id)}">${escape(m.org)}</a> <span class="muted">(${escape(m.role)})</span>`).join(" · ")
|
|
167
|
+
: '<span class="muted">none</span>';
|
|
168
|
+
const status = u.verified
|
|
169
|
+
? '<span class="ok">verified</span>'
|
|
170
|
+
: `<span class="warn">unverified</span>
|
|
171
|
+
<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>`;
|
|
173
|
+
return `<tr class="row">
|
|
174
|
+
<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
|
+
<td class="nowrap">${status}</td>
|
|
176
|
+
<td>${memberships}</td>
|
|
177
|
+
<td class="right">
|
|
178
|
+
<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>
|
|
179
|
+
${self
|
|
180
|
+
? ""
|
|
181
|
+
: `<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>
|
|
182
|
+
<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>`}
|
|
183
|
+
</td>
|
|
184
|
+
</tr>`;
|
|
185
|
+
})
|
|
186
|
+
.join("\n");
|
|
187
|
+
body = `
|
|
188
|
+
<div class="card">
|
|
189
|
+
<h2>Users <span class="muted">(${d.users.length})</span></h2>
|
|
190
|
+
<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>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="card panel">
|
|
194
|
+
<form class="grid" method="post" action="${usersPath}">${hidden({ action: "add", tab: "users" })}
|
|
195
|
+
<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>
|
|
198
|
+
</form>
|
|
199
|
+
</div>`;
|
|
200
|
+
}
|
|
201
|
+
// ── settings ────────────────────────────────────────────────
|
|
202
|
+
if (d.view.tab === "settings") {
|
|
203
|
+
const permissionRows = Object.entries(o.permissions)
|
|
204
|
+
.map(([role, perms]) => ` ${role}: ${perms.join(" ") || "(nothing)"}`)
|
|
205
|
+
.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
|
+
body = `
|
|
210
|
+
<div class="card">
|
|
211
|
+
<h2>Settings <span class="muted">· read-only, from b17_auth.yaml and .env.local</span></h2>
|
|
212
|
+
<table><tbody>
|
|
213
|
+
<tr><td>name</td><td>${escape(o.name || "(none: the request's host)")}</td></tr>
|
|
214
|
+
<tr><td>signup</td><td>${escape(o.signup)}${o.emailDomain ? ` · only @${escape(o.emailDomain)}` : ""}</td></tr>
|
|
215
|
+
<tr><td>requireVerified</td><td>${o.requireVerified}</td></tr>
|
|
216
|
+
<tr><td>sessionDays</td><td>${o.sessionDays}</td></tr>
|
|
217
|
+
<tr><td>${o.protected.length ? "protected" : "public"}</td><td>${escape((o.protected.length ? o.protected : o.public).join(" ") || "(everything needs sign-in)")}</td></tr>
|
|
218
|
+
<tr><td>admins</td><td>${escape(o.admins.join(" "))}</td></tr>
|
|
219
|
+
<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
|
+
<tr><td>tokens</td><td>${d.tokens.total} issued · ${d.tokens.live} still usable</td></tr>
|
|
221
|
+
</tbody></table>
|
|
222
|
+
<pre>permissions:
|
|
223
|
+
${escape(permissionRows) || " (none)"}${orgOverrides ? `\norganizations:\n${escape(orgOverrides)}` : ""}</pre>
|
|
224
|
+
</div>`;
|
|
225
|
+
}
|
|
226
|
+
return `<!doctype html>
|
|
227
|
+
<html lang="en">
|
|
228
|
+
<head>
|
|
229
|
+
<meta charset="utf-8">
|
|
230
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
231
|
+
<title>Admin${o.name ? ` · ${escape(o.name)}` : ""}</title>
|
|
232
|
+
<style>${STYLE}</style>
|
|
233
|
+
</head>
|
|
234
|
+
<body>
|
|
235
|
+
<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">
|
|
238
|
+
<a href="${link("orgs")}" class="${tab === "orgs" ? "on" : ""}">Organizations</a>
|
|
239
|
+
<a href="${link("users")}" class="${tab === "users" ? "on" : ""}">Users</a>
|
|
240
|
+
<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>` : ""}
|
|
243
|
+
${body}
|
|
244
|
+
</main>
|
|
245
|
+
<script nonce="${escape(d.nonce)}">
|
|
246
|
+
for (const f of document.querySelectorAll("form[data-confirm]")) {
|
|
247
|
+
f.addEventListener("submit", (e) => { if (!confirm(f.dataset.confirm)) e.preventDefault() })
|
|
248
|
+
}
|
|
249
|
+
</script>
|
|
250
|
+
</body>
|
|
251
|
+
</html>`;
|
|
252
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
+
/** 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"];
|
|
6
|
+
export declare const ORG_ACTIONS: readonly ["create", "add-member", "set-role", "remove-member", "remove-org"];
|
|
7
|
+
export type UserAction = (typeof USER_ACTIONS)[number];
|
|
8
|
+
export type OrgAction = (typeof ORG_ACTIONS)[number];
|
|
9
|
+
export declare const isUserAction: (a: string) => a is UserAction;
|
|
10
|
+
export declare const isOrgAction: (a: string) => a is OrgAction;
|
|
11
|
+
/**
|
|
12
|
+
* What an admin may not do to themselves: remove or lock out the account
|
|
13
|
+
* they are using. (Their own password change is fine: it ends their other
|
|
14
|
+
* sessions, and the page signs them in again.)
|
|
15
|
+
*/
|
|
16
|
+
export declare function refusedOnSelf(action: UserAction, targetEmail: string, selfEmail: string): string | null;
|
|
17
|
+
/**
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
export declare function backTo(fields: {
|
|
22
|
+
org?: string;
|
|
23
|
+
tab?: string;
|
|
24
|
+
}): string;
|
|
25
|
+
/** Roles the page offers in its dropdowns: whatever the yaml defines, plus any role already in use. */
|
|
26
|
+
export declare function roleChoices(defined: readonly string[], inUse: readonly string[]): string[];
|
|
@@ -0,0 +1,48 @@
|
|
|
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 and memberships. Never the yaml.
|
|
7
|
+
//
|
|
8
|
+
// An admin is an email, not a role: a role lives in one organization,
|
|
9
|
+
// the admin page spans all of them.
|
|
10
|
+
// ─────────────────────────────────────────────────────────────
|
|
11
|
+
export const ADMIN_PATH = "/auth/admin";
|
|
12
|
+
export function isAdmin(session, admins) {
|
|
13
|
+
return !!session && admins.includes(session.user.email.toLowerCase());
|
|
14
|
+
}
|
|
15
|
+
/** 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"];
|
|
17
|
+
export const ORG_ACTIONS = ["create", "add-member", "set-role", "remove-member", "remove-org"];
|
|
18
|
+
export const isUserAction = (a) => USER_ACTIONS.includes(a);
|
|
19
|
+
export const isOrgAction = (a) => ORG_ACTIONS.includes(a);
|
|
20
|
+
/**
|
|
21
|
+
* What an admin may not do to themselves: remove or lock out the account
|
|
22
|
+
* they are using. (Their own password change is fine: it ends their other
|
|
23
|
+
* sessions, and the page signs them in again.)
|
|
24
|
+
*/
|
|
25
|
+
export function refusedOnSelf(action, targetEmail, selfEmail) {
|
|
26
|
+
if (targetEmail.toLowerCase() !== selfEmail.toLowerCase())
|
|
27
|
+
return null;
|
|
28
|
+
if (action === "remove")
|
|
29
|
+
return "You cannot remove the account you are signed in with.";
|
|
30
|
+
if (action === "sign-out")
|
|
31
|
+
return "Use /auth/sign-out to end your own session.";
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
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.
|
|
37
|
+
*/
|
|
38
|
+
export function backTo(fields) {
|
|
39
|
+
if (fields.org && /^[0-9a-f-]{36}$/i.test(fields.org))
|
|
40
|
+
return `org=${fields.org}`;
|
|
41
|
+
if (fields.tab === "users" || fields.tab === "settings")
|
|
42
|
+
return `tab=${fields.tab}`;
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
/** Roles the page offers in its dropdowns: whatever the yaml defines, plus any role already in use. */
|
|
46
|
+
export function roleChoices(defined, inUse) {
|
|
47
|
+
return [...new Set(["owner", "admin", "member", ...defined, ...inUse])];
|
|
48
|
+
}
|
package/dist/core/config.d.ts
CHANGED
|
@@ -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
10
|
export declare const SIGN_IN_PATH = "/auth/sign-in";
|
|
4
11
|
export declare const SIGN_OUT_PATH = "/auth/sign-out";
|
|
5
12
|
export declare const SIGN_UP_PATH = "/auth/sign-up";
|
|
6
|
-
export declare const TABLE = "
|
|
13
|
+
export declare const TABLE = "users";
|
|
7
14
|
export declare function getSecret(): string;
|
|
8
15
|
export declare function getDatabaseUrl(): string;
|
package/dist/core/config.js
CHANGED
|
@@ -1,24 +1,38 @@
|
|
|
1
1
|
// ─────────────────────────────────────────────────────────────
|
|
2
|
-
//
|
|
2
|
+
// Constants, and the two values nothing works without.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
23
|
export const SIGN_IN_PATH = "/auth/sign-in";
|
|
10
24
|
export const SIGN_OUT_PATH = "/auth/sign-out";
|
|
11
25
|
export const SIGN_UP_PATH = "/auth/sign-up";
|
|
12
|
-
export const TABLE = "
|
|
26
|
+
export const TABLE = "users";
|
|
13
27
|
export function getSecret() {
|
|
14
|
-
const 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 =
|
|
35
|
+
const url = options().database;
|
|
22
36
|
if (!url) {
|
|
23
37
|
throw new Error("DATABASE_URL is missing. Run: npx @binary1702/auth");
|
|
24
38
|
}
|
package/dist/core/db.d.ts
CHANGED
|
@@ -1,16 +1,147 @@
|
|
|
1
1
|
import { TABLE } from "./config.ts";
|
|
2
2
|
import type { User } from "./session.ts";
|
|
3
|
-
export
|
|
4
|
-
|
|
3
|
+
export type Statement = {
|
|
4
|
+
text: string;
|
|
5
|
+
params?: unknown[];
|
|
6
|
+
};
|
|
7
|
+
/** What the package needs from a database: parameterised queries, and a transaction if it has one. */
|
|
8
|
+
export interface Db {
|
|
9
|
+
query<T = Record<string, unknown>>(text: string, params?: unknown[]): Promise<T[]>;
|
|
10
|
+
/** Run statements in one transaction. A store without it runs them one by one. */
|
|
11
|
+
transaction?(statements: Statement[]): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
/** The real store, created once per process from DATABASE_URL. */
|
|
14
|
+
export declare function db(): Db;
|
|
15
|
+
export type Migration = {
|
|
16
|
+
id: number;
|
|
17
|
+
name: string;
|
|
18
|
+
statements: string[];
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Ordered, append-only. Never edit a step that has shipped: add a new
|
|
22
|
+
* one. Step 1 is the 0.2.5 schema exactly, so an existing install
|
|
23
|
+
* applies it as a no-op through IF NOT EXISTS.
|
|
24
|
+
*/
|
|
25
|
+
export declare const MIGRATIONS: Migration[];
|
|
26
|
+
/**
|
|
27
|
+
* The package's tables have plain names (users, orgs, members, tokens).
|
|
28
|
+
* A database that already holds a `users` table of its own would be
|
|
29
|
+
* silently shared by CREATE TABLE IF NOT EXISTS, so a fresh install
|
|
30
|
+
* checks first. Pure: decides from what the store reports.
|
|
31
|
+
*/
|
|
32
|
+
export declare function foreignUsersTable(columns: string[]): boolean;
|
|
33
|
+
export declare const FOREIGN_USERS_TABLE = "This database already has a `users` table that is not this package's. Point DATABASE_URL at a database (or Postgres schema) of its own.";
|
|
34
|
+
/** The steps still to run, in order. Pure. */
|
|
35
|
+
export declare function pendingSteps(applied: number[], steps?: Migration[]): Migration[];
|
|
36
|
+
/** Everything a pending step needs to run, including recording that it ran. Pure. */
|
|
37
|
+
export declare function migrationStatements(steps: Migration[]): Statement[];
|
|
38
|
+
/**
|
|
39
|
+
* Apply pending migrations. Memoized per store and per process, so
|
|
40
|
+
* route handlers can call it on every request for free after the first.
|
|
41
|
+
* A failure clears the memo so the next call retries.
|
|
42
|
+
*/
|
|
43
|
+
export declare function ensureSchema(store?: Db): Promise<void>;
|
|
44
|
+
export type UserRow = {
|
|
5
45
|
id: string;
|
|
6
46
|
email: string;
|
|
7
47
|
password_hash: string;
|
|
48
|
+
epoch: number;
|
|
49
|
+
/** When the email was verified, or null */
|
|
50
|
+
verified_at: string | Date | null;
|
|
51
|
+
};
|
|
52
|
+
export declare function findUser(email: string, store?: Db): Promise<UserRow | null>;
|
|
53
|
+
/** Two sign-ups raced on one email and the second lost: the handler answers 409. */
|
|
54
|
+
export declare class DuplicateUserError extends Error {
|
|
55
|
+
constructor(email: string);
|
|
56
|
+
}
|
|
57
|
+
/** Postgres's unique_violation */
|
|
58
|
+
export declare const isUniqueViolation: (err: unknown) => boolean;
|
|
59
|
+
export declare function createUser(email: string, passwordHash: string, opts?: {
|
|
60
|
+
verified?: boolean;
|
|
61
|
+
}, store?: Db): Promise<User & {
|
|
62
|
+
epoch: number;
|
|
63
|
+
verified: boolean;
|
|
64
|
+
}>;
|
|
65
|
+
/** Replace a hash with one made under stronger parameters. No epoch bump: nothing about the session changes. */
|
|
66
|
+
export declare function rehashPassword(id: string, passwordHash: string, store?: Db): Promise<void>;
|
|
67
|
+
/** What a session check needs about the user: current epoch and verification. Null if the user is gone. */
|
|
68
|
+
export type UserState = {
|
|
69
|
+
epoch: number;
|
|
70
|
+
verified: boolean;
|
|
71
|
+
};
|
|
72
|
+
export declare function userState(id: string, store?: Db): Promise<UserState | null>;
|
|
73
|
+
/** The user's current session_epoch, or null if the user is gone */
|
|
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. */
|
|
76
|
+
export type SessionRow = UserState & {
|
|
77
|
+
id: string;
|
|
78
|
+
email: string;
|
|
79
|
+
org_id: string | null;
|
|
80
|
+
org_name: string | null;
|
|
81
|
+
role: string | null;
|
|
82
|
+
};
|
|
83
|
+
export declare function sessionRow(id: string, store?: Db): Promise<SessionRow | null>;
|
|
84
|
+
export type OrgRow = {
|
|
85
|
+
id: string;
|
|
86
|
+
name: string;
|
|
87
|
+
created_at: string;
|
|
88
|
+
};
|
|
89
|
+
export declare function createOrg(name: string, store?: Db): Promise<OrgRow>;
|
|
90
|
+
/** By id (UUID) or exact name. Null if there is no such organization. */
|
|
91
|
+
export declare function findOrg(idOrName: string, store?: Db): Promise<OrgRow | null>;
|
|
92
|
+
export declare function listOrgs(store?: Db): Promise<(OrgRow & {
|
|
93
|
+
members: number;
|
|
94
|
+
})[]>;
|
|
95
|
+
/** Add or update a membership. Becomes the user's active org if they had none. */
|
|
96
|
+
export declare function addMember(orgId: string, userId: string, role: string, store?: Db): Promise<void>;
|
|
97
|
+
export declare function removeMember(orgId: string, userId: string, store?: Db): Promise<boolean>;
|
|
98
|
+
export declare function removeOrg(orgId: string, store?: Db): Promise<boolean>;
|
|
99
|
+
export type Membership = {
|
|
100
|
+
org_id: string;
|
|
101
|
+
org: string;
|
|
102
|
+
role: string;
|
|
103
|
+
};
|
|
104
|
+
export type AdminUser = {
|
|
105
|
+
id: string;
|
|
106
|
+
email: string;
|
|
107
|
+
created_at: string;
|
|
108
|
+
verified: boolean;
|
|
109
|
+
memberships: Membership[];
|
|
110
|
+
};
|
|
111
|
+
export type AdminOrg = {
|
|
112
|
+
id: string;
|
|
113
|
+
name: string;
|
|
114
|
+
created_at: string;
|
|
115
|
+
members: {
|
|
116
|
+
user_id: string;
|
|
117
|
+
email: string;
|
|
118
|
+
role: string;
|
|
119
|
+
}[];
|
|
8
120
|
};
|
|
9
|
-
|
|
10
|
-
export declare function
|
|
11
|
-
|
|
12
|
-
export declare function
|
|
121
|
+
/** Every user with their memberships, one query. */
|
|
122
|
+
export declare function adminUsers(store?: Db): Promise<AdminUser[]>;
|
|
123
|
+
/** Every organization with its members, one query. */
|
|
124
|
+
export declare function adminOrgs(store?: Db): Promise<AdminOrg[]>;
|
|
125
|
+
/** Switch the user's active organization. False unless they are a member of it: the client's choice is checked, never trusted. */
|
|
126
|
+
export declare function setActiveOrg(userId: string, orgId: string, store?: Db): Promise<boolean>;
|
|
127
|
+
/** Record that the user followed the verification link. Idempotent. Null if the user is gone. */
|
|
128
|
+
export declare function markVerified(id: string, store?: Db): Promise<User | null>;
|
|
129
|
+
/** Revoke every session of this user. False if there is no such user. */
|
|
130
|
+
export declare function revokeSessions(email: string, store?: Db): Promise<boolean>;
|
|
131
|
+
/** Delete the user. Their sessions die with the row. False if none. */
|
|
132
|
+
export declare function removeUser(email: string, store?: Db): Promise<boolean>;
|
|
133
|
+
export declare function countUsers(store?: Db): Promise<number>;
|
|
134
|
+
export declare function listUsers(store?: Db): Promise<(User & {
|
|
13
135
|
created_at: string;
|
|
14
136
|
})[]>;
|
|
15
|
-
|
|
137
|
+
/** Change a password. Also revokes every session, old password's included. */
|
|
138
|
+
export declare function setPassword(email: string, passwordHash: string, store?: Db): Promise<boolean>;
|
|
139
|
+
/**
|
|
140
|
+
* Same, by id, for a reset link. Also marks the email verified: following
|
|
141
|
+
* the link proved the address. Returns the user with the new epoch so the
|
|
142
|
+
* caller can sign them in; null if the user is gone.
|
|
143
|
+
*/
|
|
144
|
+
export declare function setPasswordById(id: string, passwordHash: string, store?: Db): Promise<(User & {
|
|
145
|
+
epoch: number;
|
|
146
|
+
}) | null>;
|
|
16
147
|
export { TABLE };
|