@binary1702/auth 0.2.5 → 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 +27 -16
- package/PROMPT.md +324 -36
- package/README.md +279 -51
- package/dist/cli.js +232 -17
- 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 +131 -10
- package/dist/core/db.js +388 -69
- 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 +11 -1
- package/dist/core/session.js +27 -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 +10 -3
- package/dist/next/index.js +444 -52
- package/dist/next/proxy.d.ts +2 -1
- package/dist/next/proxy.js +11 -7
- package/dist/react/index.d.ts +18 -0
- package/dist/react/index.js +55 -0
- package/package.json +16 -3
package/dist/cli.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// 3. AUTH_SECRET → generate, write .env.local
|
|
11
11
|
// 4. app/auth/[...path]/route.ts
|
|
12
12
|
// 5. proxy.ts
|
|
13
|
-
// 6. create
|
|
13
|
+
// 6. create users table
|
|
14
14
|
// 7. create the first user
|
|
15
15
|
//
|
|
16
16
|
// Subcommands:
|
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
// remove-user [email] delete a user (signs them out everywhere)
|
|
20
20
|
// sign-out-everywhere [email] end every session of a user
|
|
21
21
|
// list-users
|
|
22
|
+
// add-org <name> create an organization
|
|
23
|
+
// add-member <email> <org> [role] put a user in an org (role defaults to "member")
|
|
24
|
+
// remove-member <email> <org>
|
|
25
|
+
// list-orgs
|
|
22
26
|
// ─────────────────────────────────────────────────────────────
|
|
23
27
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
24
28
|
import { join, dirname } from "node:path";
|
|
@@ -77,7 +81,7 @@ function closePrompts() {
|
|
|
77
81
|
}
|
|
78
82
|
async function ask(question, opts = {}) {
|
|
79
83
|
rl();
|
|
80
|
-
const suffix = opts.default ? c.dim(` (${opts.default})`) : "";
|
|
84
|
+
const suffix = opts.default ? c.dim(` (${opts.default})`) : opts.optional ? c.dim(" (Enter to skip)") : "";
|
|
81
85
|
stdout.write(` ${question}${suffix}: `);
|
|
82
86
|
if (opts.hidden)
|
|
83
87
|
muted = true;
|
|
@@ -90,8 +94,12 @@ async function ask(question, opts = {}) {
|
|
|
90
94
|
muted = false;
|
|
91
95
|
stdout.write("\n");
|
|
92
96
|
}
|
|
93
|
-
if (line === null)
|
|
97
|
+
if (line === null) {
|
|
98
|
+
// Piped input that stops short: an optional prompt is simply skipped
|
|
99
|
+
if (opts.optional)
|
|
100
|
+
return "";
|
|
94
101
|
return fail("Input ended before all prompts were answered.");
|
|
102
|
+
}
|
|
95
103
|
return opts.hidden ? line : line.trim() || opts.default || "";
|
|
96
104
|
}
|
|
97
105
|
// ── env file ──────────────────────────────────────────────────
|
|
@@ -118,11 +126,14 @@ const PROXY_FILE = `import { proxy } from "${PKG}/next/proxy"
|
|
|
118
126
|
|
|
119
127
|
export default proxy
|
|
120
128
|
|
|
121
|
-
// Everything except
|
|
129
|
+
// Everything except Next's build output. The proxy itself lets the
|
|
130
|
+
// auth pages and static files through.
|
|
122
131
|
export const config = {
|
|
123
|
-
matcher: ["/((?!
|
|
132
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
124
133
|
}
|
|
125
134
|
`;
|
|
135
|
+
// Written by 0.2.5 and earlier: skipped /authors, /api/export.csv, …
|
|
136
|
+
const OLD_MATCHER = "(?!auth|_next/static|_next/image|favicon.ico|.*";
|
|
126
137
|
function writeIfMissing(rel, content) {
|
|
127
138
|
const abs = join(cwd, rel);
|
|
128
139
|
if (existsSync(abs)) {
|
|
@@ -156,7 +167,7 @@ function stepInstall() {
|
|
|
156
167
|
fail(`npm install ${PKG} failed`);
|
|
157
168
|
done(`${PKG} installed`);
|
|
158
169
|
}
|
|
159
|
-
async function stepEnv() {
|
|
170
|
+
async function stepEnv(opts = {}) {
|
|
160
171
|
const env = readEnv();
|
|
161
172
|
if (env.DATABASE_URL) {
|
|
162
173
|
process.env.DATABASE_URL = env.DATABASE_URL;
|
|
@@ -178,12 +189,88 @@ async function stepEnv() {
|
|
|
178
189
|
writeEnv("AUTH_SECRET", randomBytes(32).toString("base64url"));
|
|
179
190
|
done("AUTH_SECRET generated → .env.local");
|
|
180
191
|
}
|
|
192
|
+
// Email: verification and password-reset links. Optional. Without it,
|
|
193
|
+
// development prints the emails to the console and production refuses
|
|
194
|
+
// to send, so the install still works. See core/email.ts.
|
|
195
|
+
for (const k of ["AUTH_GMAIL_USER", "AUTH_GMAIL_CLIENT_EMAIL", "AUTH_GMAIL_PRIVATE_KEY", "AUTH_RESEND_API_KEY", "AUTH_EMAIL_FROM"]) {
|
|
196
|
+
if (env[k])
|
|
197
|
+
process.env[k] = env[k];
|
|
198
|
+
}
|
|
199
|
+
if (env.AUTH_GMAIL_USER && env.AUTH_GMAIL_CLIENT_EMAIL && env.AUTH_GMAIL_PRIVATE_KEY) {
|
|
200
|
+
if (opts.askEmail)
|
|
201
|
+
skip("email: Gmail API as " + env.AUTH_GMAIL_USER);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (env.AUTH_RESEND_API_KEY && env.AUTH_EMAIL_FROM) {
|
|
205
|
+
if (opts.askEmail)
|
|
206
|
+
skip("email: Resend from " + env.AUTH_EMAIL_FROM);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!opts.askEmail)
|
|
210
|
+
return; // subcommands only need the database
|
|
211
|
+
// Once b17_auth.yaml exists, email is configured there (its comments show how); stop asking
|
|
212
|
+
if (existsSync(join(cwd, CONFIG_FILE)))
|
|
213
|
+
return;
|
|
214
|
+
console.log(c.dim(" Email for verification and password reset. gmail = Gmail API with a service account,"));
|
|
215
|
+
console.log(c.dim(" resend = resend.com API key. Skip it for now if you like."));
|
|
216
|
+
const which = (await ask("Email transport (gmail / resend)", { optional: true })).toLowerCase();
|
|
217
|
+
if (which === "gmail") {
|
|
218
|
+
const user = await ask("Mailbox to send as (AUTH_GMAIL_USER)", { default: env.AUTH_GMAIL_USER });
|
|
219
|
+
if (!user.includes("@"))
|
|
220
|
+
fail("That does not look like an email address.");
|
|
221
|
+
const path = await ask("Path to the service account's JSON key file");
|
|
222
|
+
const abs = path.startsWith("/") ? path : join(cwd, path);
|
|
223
|
+
if (!existsSync(abs))
|
|
224
|
+
fail(`No file at ${abs}`);
|
|
225
|
+
let sa;
|
|
226
|
+
try {
|
|
227
|
+
sa = JSON.parse(readFileSync(abs, "utf8"));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return fail("That file is not JSON.");
|
|
231
|
+
}
|
|
232
|
+
if (!sa.client_email || !sa.private_key)
|
|
233
|
+
fail("That JSON has no client_email / private_key: it is not a service account key.");
|
|
234
|
+
writeEnv("AUTH_GMAIL_USER", user);
|
|
235
|
+
writeEnv("AUTH_GMAIL_CLIENT_EMAIL", sa.client_email);
|
|
236
|
+
// One line: the PEM's newlines as the two characters \n, which the package restores
|
|
237
|
+
writeEnv("AUTH_GMAIL_PRIVATE_KEY", sa.private_key.replace(/\n/g, "\\n"));
|
|
238
|
+
done(`AUTH_GMAIL_USER, AUTH_GMAIL_CLIENT_EMAIL, AUTH_GMAIL_PRIVATE_KEY → .env.local (${sa.client_email})`);
|
|
239
|
+
console.log(c.dim(` In Google Admin, grant that service account domain-wide delegation for the scope:`));
|
|
240
|
+
console.log(c.dim(` https://www.googleapis.com/auth/gmail.send`));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (which === "resend") {
|
|
244
|
+
const key = env.AUTH_RESEND_API_KEY || (await ask("Resend API key (AUTH_RESEND_API_KEY)", { hidden: true }));
|
|
245
|
+
if (!key.trim())
|
|
246
|
+
fail("No key given.");
|
|
247
|
+
const from = env.AUTH_EMAIL_FROM || (await ask("From address (AUTH_EMAIL_FROM)", { default: "Auth <onboarding@resend.dev>" }));
|
|
248
|
+
if (!env.AUTH_RESEND_API_KEY)
|
|
249
|
+
writeEnv("AUTH_RESEND_API_KEY", key.trim());
|
|
250
|
+
if (!env.AUTH_EMAIL_FROM)
|
|
251
|
+
writeEnv("AUTH_EMAIL_FROM", from);
|
|
252
|
+
done("AUTH_RESEND_API_KEY, AUTH_EMAIL_FROM → .env.local");
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (which)
|
|
256
|
+
fail(`Unknown transport "${which}". Use gmail, resend, or press Enter to skip.`);
|
|
257
|
+
skip("email not configured: dev prints emails to the console");
|
|
181
258
|
}
|
|
182
259
|
function stepFiles(appDir, src) {
|
|
183
260
|
writeIfMissing(`${appDir}/auth/[...path]/route.ts`, ROUTE_FILE);
|
|
184
261
|
const proxyPath = src ? "src/proxy.ts" : "proxy.ts";
|
|
185
262
|
const legacy = [src ? "src/middleware.ts" : "middleware.ts", proxyPath].filter((p) => existsSync(join(cwd, p)));
|
|
186
263
|
if (legacy.length) {
|
|
264
|
+
const text = readFileSync(join(cwd, legacy[0]), "utf8");
|
|
265
|
+
if (text.includes(`${PKG}/next/proxy`) && !text.includes(OLD_MATCHER)) {
|
|
266
|
+
skip(`${legacy[0]} exists`); // ours, current: nothing to say
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (text.includes(OLD_MATCHER)) {
|
|
270
|
+
console.log(` ${c.red("!")} ${legacy[0]} has the old matcher, which leaves /auth* and dotted paths unguarded.`);
|
|
271
|
+
console.log(` Replace its matcher with: ${c.bold('["/((?!_next/static|_next/image|favicon.ico).*)"]')}`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
187
274
|
skip(`${legacy[0]} exists, not touched. Add to it:`);
|
|
188
275
|
console.log(c.dim(PROXY_FILE.replace(/^/gm, " ")));
|
|
189
276
|
return;
|
|
@@ -194,10 +281,94 @@ async function db() {
|
|
|
194
281
|
// Imported lazily: needs DATABASE_URL in process.env first.
|
|
195
282
|
return import("./core/db.js");
|
|
196
283
|
}
|
|
284
|
+
// ── b17_auth.yaml ─────────────────────────────────────────────
|
|
285
|
+
const CONFIG_FILE = "b17_auth.yaml";
|
|
286
|
+
function appName() {
|
|
287
|
+
try {
|
|
288
|
+
const name = String(JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")).name ?? "");
|
|
289
|
+
return name.replace(/^@[^/]+\//, "").replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return "My App";
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/** The file the install writes once. Secrets are ${…} references into .env.local. */
|
|
296
|
+
function configTemplate(env) {
|
|
297
|
+
const gmail = env.AUTH_GMAIL_USER && env.AUTH_GMAIL_CLIENT_EMAIL && env.AUTH_GMAIL_PRIVATE_KEY;
|
|
298
|
+
const resend = env.AUTH_RESEND_API_KEY;
|
|
299
|
+
const from = env.AUTH_EMAIL_FROM || (gmail ? env.AUTH_GMAIL_USER : "");
|
|
300
|
+
return `# ${CONFIG_FILE} · @binary1702/auth
|
|
301
|
+
# One place for all of it. Secrets stay in .env.local and appear here as \${NAME}.
|
|
302
|
+
# Docs: https://www.npmjs.com/package/@binary1702/auth#b17_authyaml
|
|
303
|
+
|
|
304
|
+
name: ${appName()} # page titles, email subjects
|
|
305
|
+
signup: closed # open | closed | invite (invite also needs inviteCode)
|
|
306
|
+
# inviteCode: \${AUTH_INVITE_CODE}
|
|
307
|
+
# emailDomain: example.com # only these addresses may sign up
|
|
308
|
+
# requireVerified: true # no session until the email link is clicked
|
|
309
|
+
# protected: /app/* /api/* # only these need sign-in; everything else is open. * = anything below
|
|
310
|
+
# # (or the reverse: public: / /pricing = everything needs sign-in except these)
|
|
311
|
+
# admins: you@example.com # who may open /auth/admin (users, organizations, roles)
|
|
312
|
+
|
|
313
|
+
database: \${DATABASE_URL}
|
|
314
|
+
secret: \${AUTH_SECRET}
|
|
315
|
+
|
|
316
|
+
email:
|
|
317
|
+
${from ? ` from: ${from}` : " # from: NoReply (My App) <no-reply@example.com>"}
|
|
318
|
+
# replyTo: support@example.com
|
|
319
|
+
${gmail
|
|
320
|
+
? ` gmail:
|
|
321
|
+
user: ${env.AUTH_GMAIL_USER} # the mailbox the service account acts as
|
|
322
|
+
clientEmail: \${AUTH_GMAIL_CLIENT_EMAIL}
|
|
323
|
+
privateKey: \${AUTH_GMAIL_PRIVATE_KEY}`
|
|
324
|
+
: resend
|
|
325
|
+
? ` resend:
|
|
326
|
+
apiKey: \${AUTH_RESEND_API_KEY}`
|
|
327
|
+
: ` # gmail: # one of the two; see the README
|
|
328
|
+
# user: you@example.com
|
|
329
|
+
# clientEmail: \${AUTH_GMAIL_CLIENT_EMAIL}
|
|
330
|
+
# privateKey: \${AUTH_GMAIL_PRIVATE_KEY}
|
|
331
|
+
# resend:
|
|
332
|
+
# apiKey: \${AUTH_RESEND_API_KEY}`}
|
|
333
|
+
|
|
334
|
+
permissions: # role: what it may do. all = everything, x.* = a prefix
|
|
335
|
+
owner: all
|
|
336
|
+
admin: all
|
|
337
|
+
member:
|
|
338
|
+
|
|
339
|
+
# organizations: # overrides by organization name
|
|
340
|
+
# Acme:
|
|
341
|
+
# member: invoices.read
|
|
342
|
+
`;
|
|
343
|
+
}
|
|
344
|
+
async function stepConfig() {
|
|
345
|
+
const abs = join(cwd, CONFIG_FILE);
|
|
346
|
+
if (existsSync(abs)) {
|
|
347
|
+
const { parseYaml } = await import("./core/yaml.js");
|
|
348
|
+
const { literalSecrets } = await import("./core/options.js");
|
|
349
|
+
let bad = [];
|
|
350
|
+
try {
|
|
351
|
+
bad = literalSecrets(parseYaml(readFileSync(abs, "utf8")));
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
fail(`${CONFIG_FILE}: ${e instanceof Error ? e.message : String(e)}`);
|
|
355
|
+
}
|
|
356
|
+
if (bad.length) {
|
|
357
|
+
const ignored = spawnSync("git", ["check-ignore", "-q", CONFIG_FILE], { cwd }).status === 0;
|
|
358
|
+
if (!ignored) {
|
|
359
|
+
fail(`${CONFIG_FILE} holds a literal secret under ${bad.join(", ")}. Move it to .env.local and reference it as \${NAME}, or git-ignore the file.`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
skip(`${CONFIG_FILE} exists`);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
writeFileSync(abs, configTemplate(readEnv()));
|
|
366
|
+
done(`${CONFIG_FILE} written: app name, sign-up policy, email, permissions`);
|
|
367
|
+
}
|
|
197
368
|
async function stepSchema() {
|
|
198
|
-
const { ensureSchema } = await db();
|
|
369
|
+
const { ensureSchema, MIGRATIONS } = await db();
|
|
199
370
|
await ensureSchema().catch((e) => fail(`Could not reach the database: ${e.message}`));
|
|
200
|
-
done(
|
|
371
|
+
done(`database schema ready (${MIGRATIONS.length} migration${MIGRATIONS.length === 1 ? "" : "s"})`);
|
|
201
372
|
}
|
|
202
373
|
async function createUserInteractive(presetEmail) {
|
|
203
374
|
const { createUser, findUser } = await db();
|
|
@@ -213,7 +384,8 @@ async function createUserInteractive(presetEmail) {
|
|
|
213
384
|
const again = await ask("Password again", { hidden: true });
|
|
214
385
|
if (password !== again)
|
|
215
386
|
fail("Passwords do not match.");
|
|
216
|
-
|
|
387
|
+
// Created by whoever runs the CLI: no verification email to wait for
|
|
388
|
+
await createUser(email, await hashPassword(password), { verified: true });
|
|
217
389
|
done(`user "${email}" created`);
|
|
218
390
|
}
|
|
219
391
|
async function stepFirstUser() {
|
|
@@ -230,15 +402,15 @@ function finish() {
|
|
|
230
402
|
console.log(`
|
|
231
403
|
${c.bold("Done.")} Start the dev server and open ${c.bold("/auth/sign-in")}.
|
|
232
404
|
|
|
233
|
-
Get the
|
|
405
|
+
Get the session in any route, server component or action:
|
|
234
406
|
|
|
235
|
-
import {
|
|
236
|
-
const
|
|
407
|
+
import { getSession, requirePermission } from "${PKG}/next"
|
|
408
|
+
const session = await getSession() // { user, organization, authorization } or null
|
|
237
409
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
410
|
+
Settings live in ${c.bold(CONFIG_FILE)}: app name, who may sign up (signup: open | invite),
|
|
411
|
+
which paths need sign-in (protected:), email, roles → permissions. Secrets stay in .env.local.
|
|
412
|
+
Add users later: npx ${PKG} add-user Organizations: npx ${PKG} add-org <name>
|
|
413
|
+
Working with an AI agent in this app? Point it at node_modules/${PKG}/AGENTS.md
|
|
242
414
|
`);
|
|
243
415
|
}
|
|
244
416
|
// ── main ──────────────────────────────────────────────────────
|
|
@@ -252,11 +424,53 @@ async function main() {
|
|
|
252
424
|
npx ${PKG} remove-user [email] delete; also signs them out everywhere
|
|
253
425
|
npx ${PKG} sign-out-everywhere [email] end every session of a user
|
|
254
426
|
npx ${PKG} list-users
|
|
427
|
+
|
|
428
|
+
npx ${PKG} add-org <name> create an organization
|
|
429
|
+
npx ${PKG} add-member <email> <org> [role] add a user to an org (role: member, admin, owner, …)
|
|
430
|
+
npx ${PKG} remove-member <email> <org>
|
|
431
|
+
npx ${PKG} list-orgs
|
|
255
432
|
`);
|
|
256
433
|
return;
|
|
257
434
|
}
|
|
258
435
|
stepProject();
|
|
259
436
|
const USER_CMDS = ["add-user", "set-password", "remove-user", "sign-out-everywhere", "list-users"];
|
|
437
|
+
const ORG_CMDS = ["add-org", "add-member", "remove-member", "list-orgs"];
|
|
438
|
+
if (ORG_CMDS.includes(cmd)) {
|
|
439
|
+
await stepEnv();
|
|
440
|
+
const d = await db();
|
|
441
|
+
const [, email, orgName, role] = process.argv.slice(2);
|
|
442
|
+
const org = async (name) => {
|
|
443
|
+
if (!name)
|
|
444
|
+
fail(`Usage: npx ${PKG} ${cmd} <email> <org>`);
|
|
445
|
+
return (await d.findOrg(name)) ?? fail(`No organization "${name}". Create it: npx ${PKG} add-org "${name}"`);
|
|
446
|
+
};
|
|
447
|
+
const user = async (e) => {
|
|
448
|
+
if (!e)
|
|
449
|
+
fail(`Usage: npx ${PKG} ${cmd} <email> <org>`);
|
|
450
|
+
return (await d.findUser(e)) ?? fail(`No user "${e}".`);
|
|
451
|
+
};
|
|
452
|
+
if (cmd === "add-org") {
|
|
453
|
+
if (!arg)
|
|
454
|
+
fail(`Usage: npx ${PKG} add-org <name>`);
|
|
455
|
+
const o = await d.createOrg(arg);
|
|
456
|
+
done(`organization "${o.name}" created ${c.dim(o.id)}`);
|
|
457
|
+
}
|
|
458
|
+
if (cmd === "list-orgs") {
|
|
459
|
+
for (const o of await d.listOrgs())
|
|
460
|
+
console.log(` ${o.name} ${c.dim(o.id)} ${o.members} member${o.members === 1 ? "" : "s"}`);
|
|
461
|
+
}
|
|
462
|
+
if (cmd === "add-member") {
|
|
463
|
+
const [u, o] = [await user(email), await org(orgName)];
|
|
464
|
+
await d.addMember(o.id, u.id, role || "member");
|
|
465
|
+
done(`"${u.email}" is ${role || "member"} of "${o.name}"`);
|
|
466
|
+
}
|
|
467
|
+
if (cmd === "remove-member") {
|
|
468
|
+
const [u, o] = [await user(email), await org(orgName)];
|
|
469
|
+
(await d.removeMember(o.id, u.id)) ? done(`"${u.email}" removed from "${o.name}"`) : fail(`"${u.email}" is not a member of "${o.name}".`);
|
|
470
|
+
}
|
|
471
|
+
console.log();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
260
474
|
if (USER_CMDS.includes(cmd)) {
|
|
261
475
|
await stepEnv();
|
|
262
476
|
const d = await db();
|
|
@@ -295,8 +509,9 @@ async function main() {
|
|
|
295
509
|
fail(`Unknown command "${cmd}". Try: npx ${PKG} help`);
|
|
296
510
|
const { appDir, src } = stepProject();
|
|
297
511
|
stepInstall();
|
|
298
|
-
await stepEnv();
|
|
512
|
+
await stepEnv({ askEmail: true });
|
|
299
513
|
stepFiles(appDir, src);
|
|
514
|
+
await stepConfig();
|
|
300
515
|
await stepSchema();
|
|
301
516
|
await stepFirstUser();
|
|
302
517
|
finish();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AdminOrg, AdminUser } from "./db.ts";
|
|
2
|
+
import type { Options } from "./options.ts";
|
|
3
|
+
export type AdminView = {
|
|
4
|
+
tab: "orgs" | "users" | "settings";
|
|
5
|
+
} | {
|
|
6
|
+
tab: "org";
|
|
7
|
+
orgId: string;
|
|
8
|
+
};
|
|
9
|
+
export type AdminPageData = {
|
|
10
|
+
self: string;
|
|
11
|
+
view: AdminView;
|
|
12
|
+
users: AdminUser[];
|
|
13
|
+
orgs: AdminOrg[];
|
|
14
|
+
roles: string[];
|
|
15
|
+
options: Options;
|
|
16
|
+
transport: string;
|
|
17
|
+
tokens: {
|
|
18
|
+
total: number;
|
|
19
|
+
live: number;
|
|
20
|
+
};
|
|
21
|
+
notice?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
/** per-render, for the one script the page carries; must match the CSP header */
|
|
24
|
+
nonce: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function adminPage(d: AdminPageData): string;
|
|
@@ -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
|
+
}
|