@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.
Files changed (49) hide show
  1. package/AGENTS.md +139 -0
  2. package/CHANGELOG.md +31 -0
  3. package/INSTALL.md +27 -16
  4. package/PROMPT.md +324 -36
  5. package/README.md +279 -51
  6. package/dist/cli.js +232 -17
  7. package/dist/core/admin-html.d.ts +26 -0
  8. package/dist/core/admin-html.js +252 -0
  9. package/dist/core/admin.d.ts +26 -0
  10. package/dist/core/admin.js +48 -0
  11. package/dist/core/config.d.ts +8 -1
  12. package/dist/core/config.js +20 -6
  13. package/dist/core/db.d.ts +131 -10
  14. package/dist/core/db.js +388 -69
  15. package/dist/core/email.d.ts +49 -0
  16. package/dist/core/email.js +213 -0
  17. package/dist/core/guard.d.ts +49 -0
  18. package/dist/core/guard.js +103 -0
  19. package/dist/core/html.d.ts +36 -0
  20. package/dist/core/html.js +106 -25
  21. package/dist/core/options.d.ts +76 -0
  22. package/dist/core/options.js +310 -0
  23. package/dist/core/org.d.ts +34 -0
  24. package/dist/core/org.js +55 -0
  25. package/dist/core/password.d.ts +16 -0
  26. package/dist/core/password.js +41 -12
  27. package/dist/core/rate.d.ts +26 -0
  28. package/dist/core/rate.js +70 -0
  29. package/dist/core/reset.d.ts +9 -0
  30. package/dist/core/reset.js +31 -0
  31. package/dist/core/session.d.ts +11 -1
  32. package/dist/core/session.js +27 -7
  33. package/dist/core/signup.d.ts +9 -6
  34. package/dist/core/signup.js +26 -15
  35. package/dist/core/tokens.d.ts +29 -0
  36. package/dist/core/tokens.js +64 -0
  37. package/dist/core/verify.d.ts +8 -0
  38. package/dist/core/verify.js +33 -0
  39. package/dist/core/yaml.d.ts +10 -0
  40. package/dist/core/yaml.js +85 -0
  41. package/dist/index.d.ts +17 -6
  42. package/dist/index.js +17 -6
  43. package/dist/next/index.d.ts +10 -3
  44. package/dist/next/index.js +444 -52
  45. package/dist/next/proxy.d.ts +2 -1
  46. package/dist/next/proxy.js +11 -7
  47. package/dist/react/index.d.ts +18 -0
  48. package/dist/react/index.js +55 -0
  49. 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 auth_users table
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 the auth pages, Next internals, and static files.
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: ["/((?!auth|_next/static|_next/image|favicon.ico|.*\\\\..*).*)"],
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("auth_users table ready");
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
- await createUser(email, await hashPassword(password));
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 user in any route or server component:
405
+ Get the session in any route, server component or action:
234
406
 
235
- import { requireUser } from "${PKG}/next"
236
- const user = await requireUser() // { id, email }
407
+ import { getSession, requirePermission } from "${PKG}/next"
408
+ const session = await getSession() // { user, organization, authorization } or null
237
409
 
238
- Add users later: npx ${PKG} add-user
239
- Or let people register themselves at /auth/sign-up:
240
- AUTH_SIGNUP=open in .env.local (anyone)
241
- AUTH_INVITE_CODE=<code> optional, the form then needs it
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
+ }