sproutkit-code 1.4.0 → 1.5.1

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 (2) hide show
  1. package/dist/index.js +577 -40
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -184,6 +184,10 @@ var require_db = __commonJS({
184
184
  }
185
185
  }
186
186
  for (const col of [
187
+ // Stable per-site key encrypting admin-auth.json in Blob. MUST survive
188
+ // redeploys: SESSION_SECRET is regenerated every deploy, so deriving from
189
+ // it would make the client's self-chosen password undecryptable each time.
190
+ "auth_enc_key TEXT",
187
191
  "booking_admin_url TEXT",
188
192
  "booking_admin_password TEXT",
189
193
  "handed_over_at TEXT",
@@ -33004,10 +33008,11 @@ var require_cms_runtime = __commonJS({
33004
33008
  };
33005
33009
  })();
33006
33010
  Object.defineProperty(exports2, "__esModule", { value: true });
33007
- exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
33011
+ exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_PASSWORD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
33008
33012
  exports2.generateFriendlyPassword = generateFriendlyPassword;
33009
33013
  exports2.hashPassword = hashPassword;
33010
33014
  exports2.generateSessionSecret = generateSessionSecret;
33015
+ var node_crypto_1 = require("node:crypto");
33011
33016
  exports2.API_CONTENT_JS = `import { list, put } from "@vercel/blob";
33012
33017
 
33013
33018
  // Per-site Blob namespace so many CMS sites share one store (one token per
@@ -33167,10 +33172,131 @@ export default async function handler(req, res) {
33167
33172
  }
33168
33173
  `;
33169
33174
  exports2.API_ADMIN_LOGIN_JS = `import bcrypt from "bcryptjs";
33170
- import { createHmac } from "node:crypto";
33175
+ import { createHmac, createHash, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
33176
+ import { list, put } from "@vercel/blob";
33171
33177
 
33172
33178
  export const config = { runtime: "nodejs" };
33173
33179
 
33180
+ // Per-site Blob namespace, matching the other runtime files.
33181
+ const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
33182
+ // The client's self-chosen password, if they've changed it. ADMIN_PASSWORD_HASH
33183
+ // is a Vercel env var and the site can't rewrite its own env, so an override
33184
+ // hash lives in Blob instead and takes precedence when present.
33185
+ const AUTH_KEY = PFX + "admin-auth.json";
33186
+ // Failed-attempt counters. Blob is the only shared state a serverless function
33187
+ // has \u2014 there's no memory between invocations. Best-effort by nature: two
33188
+ // concurrent attempts can read the same counter before either writes. That
33189
+ // still turns an unlimited online guessing attack into a slow one, which is the
33190
+ // point; bcrypt's own cost does the rest.
33191
+ const THROTTLE_KEY = PFX + "admin-throttle.json";
33192
+
33193
+ const MAX_FAILS = 8; // per IP, per window
33194
+ const WINDOW_MS = 15 * 60 * 1000;
33195
+ const LOCKOUT_MS = 15 * 60 * 1000;
33196
+
33197
+
33198
+ // admin-auth.json holds a bcrypt hash and lives in a PUBLIC Blob store at a
33199
+ // non-random path, so treat its URL as readable by anyone who learns it (the
33200
+ // store hostname leaks via photo URLs; only the per-site UUID stands between an
33201
+ // outsider and this file). A leaked bcrypt hash is crackable offline where our
33202
+ // login rate limit gives zero protection \u2014 so the contents are encrypted with
33203
+ // AES-256-GCM under a key derived from AUTH_ENC_KEY, which only ever exists as
33204
+ // a server-side env var. A public read then yields opaque bytes.
33205
+ function authKey() {
33206
+ const material = process.env.AUTH_ENC_KEY || "";
33207
+ if (!material) return null;
33208
+ return createHash("sha256").update("sproutkit-admin-auth:" + material).digest();
33209
+ }
33210
+
33211
+ function sealAuth(obj) {
33212
+ const key = authKey();
33213
+ const plain = JSON.stringify(obj);
33214
+ if (!key) return JSON.stringify({ v: 0, plain: obj });
33215
+ const iv = randomBytes(12);
33216
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
33217
+ const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
33218
+ return JSON.stringify({
33219
+ v: 1,
33220
+ iv: iv.toString("base64"),
33221
+ tag: cipher.getAuthTag().toString("base64"),
33222
+ ct: ct.toString("base64"),
33223
+ });
33224
+ }
33225
+
33226
+ function openAuth(raw) {
33227
+ if (!raw || typeof raw !== "object") return null;
33228
+ // v0 = written before encryption shipped (or with no key available).
33229
+ if (raw.v === 0 && raw.plain) return raw.plain;
33230
+ if (raw.v !== 1) return null;
33231
+ const key = authKey();
33232
+ if (!key) return null;
33233
+ try {
33234
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
33235
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
33236
+ const out = Buffer.concat([
33237
+ decipher.update(Buffer.from(raw.ct, "base64")),
33238
+ decipher.final(),
33239
+ ]).toString("utf8");
33240
+ return JSON.parse(out);
33241
+ } catch {
33242
+ // Wrong key or tampered payload \u2014 fall back to the env-var password rather
33243
+ // than locking the owner out.
33244
+ return null;
33245
+ }
33246
+ }
33247
+
33248
+ async function readJson(key, fallback) {
33249
+ try {
33250
+ const { blobs } = await list({ prefix: key, limit: 1 });
33251
+ const target = blobs.find((b) => b.pathname === key);
33252
+ if (!target) return fallback;
33253
+ // cache: no-store \u2014 a stale throttle count would let attempts through.
33254
+ const res = await fetch(target.url, { cache: "no-store" });
33255
+ if (!res.ok) return fallback;
33256
+ return await res.json();
33257
+ } catch {
33258
+ return fallback;
33259
+ }
33260
+ }
33261
+
33262
+ async function writeJson(key, value) {
33263
+ try {
33264
+ await put(key, JSON.stringify(value), {
33265
+ access: "public",
33266
+ addRandomSuffix: false,
33267
+ allowOverwrite: true,
33268
+ contentType: "application/json",
33269
+ });
33270
+ } catch {
33271
+ // Non-fatal: failing to persist a counter must never lock out a real owner.
33272
+ }
33273
+ }
33274
+
33275
+ // Prefer headers Vercel sets itself. The leftmost x-forwarded-for entry is
33276
+ // client-controlled, so it's the last resort \u2014 and note a spoofer can only
33277
+ // throttle the identity they invent, not a real owner.
33278
+ function clientKey(req) {
33279
+ const h = req.headers;
33280
+ const raw =
33281
+ h["x-vercel-forwarded-for"] ||
33282
+ h["x-real-ip"] ||
33283
+ String(h["x-forwarded-for"] || "").split(",")[0].trim() ||
33284
+ "unknown";
33285
+ // Hashed because this file is world-readable in public Blob; we never want to
33286
+ // publish a list of visitor IPs.
33287
+ return createHash("sha256").update(String(raw)).digest("hex").slice(0, 16);
33288
+ }
33289
+
33290
+ function prune(state, now) {
33291
+ const out = {};
33292
+ for (const [k, v] of Object.entries(state || {})) {
33293
+ if (!v || typeof v !== "object") continue;
33294
+ const fresh = (v.until && v.until > now) || (v.first && now - v.first < WINDOW_MS);
33295
+ if (fresh) out[k] = v;
33296
+ }
33297
+ return out;
33298
+ }
33299
+
33174
33300
  function b64url(buf) {
33175
33301
  return Buffer.from(buf).toString("base64").replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
33176
33302
  }
@@ -33197,12 +33323,30 @@ export default async function handler(req, res) {
33197
33323
  res.status(405).json({ error: "method_not_allowed" });
33198
33324
  return;
33199
33325
  }
33200
- const hash = process.env.ADMIN_PASSWORD_HASH;
33201
33326
  const secret = process.env.SESSION_SECRET;
33202
- if (!hash || !secret) {
33327
+ const envHash = process.env.ADMIN_PASSWORD_HASH;
33328
+ if (!envHash || !secret) {
33203
33329
  res.status(500).json({ error: "not_configured" });
33204
33330
  return;
33205
33331
  }
33332
+
33333
+ const now = Date.now();
33334
+ const who = clientKey(req);
33335
+ let throttle = prune(await readJson(THROTTLE_KEY, {}), now);
33336
+ const entry = throttle[who];
33337
+ if (entry && entry.until && entry.until > now) {
33338
+ const retryAfter = Math.ceil((entry.until - now) / 1000);
33339
+ res.setHeader("Retry-After", String(retryAfter));
33340
+ res.status(429).json({ error: "too_many_attempts", retryAfter });
33341
+ return;
33342
+ }
33343
+
33344
+ // The client's own password wins over the one we generated at build time.
33345
+ const override = openAuth(await readJson(AUTH_KEY, null));
33346
+ const hash =
33347
+ override && typeof override.passwordHash === "string" && override.passwordHash
33348
+ ? override.passwordHash
33349
+ : envHash;
33206
33350
  let body = req.body;
33207
33351
  if (typeof body === "string") {
33208
33352
  try { body = JSON.parse(body); } catch { body = null; }
@@ -33221,10 +33365,25 @@ export default async function handler(req, res) {
33221
33365
  }
33222
33366
  const ok = await bcrypt.compare(password, hash);
33223
33367
  if (!ok) {
33368
+ const prev = throttle[who] && throttle[who].first && now - throttle[who].first < WINDOW_MS
33369
+ ? throttle[who]
33370
+ : { fails: 0, first: now };
33371
+ const fails = (prev.fails || 0) + 1;
33372
+ const next = { fails, first: prev.first || now };
33373
+ if (fails >= MAX_FAILS) next.until = now + LOCKOUT_MS;
33374
+ throttle[who] = next;
33375
+ await writeJson(THROTTLE_KEY, throttle);
33224
33376
  await new Promise((r) => setTimeout(r, 300));
33225
- res.status(401).json({ error: "invalid_password" });
33377
+ const remaining = Math.max(0, MAX_FAILS - fails);
33378
+ res.status(401).json({ error: "invalid_password", remaining });
33226
33379
  return;
33227
33380
  }
33381
+
33382
+ // Clean slate on success so a forgetful owner isn't punished later.
33383
+ if (throttle[who]) {
33384
+ delete throttle[who];
33385
+ await writeJson(THROTTLE_KEY, throttle);
33386
+ }
33228
33387
  const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 8; // 8h
33229
33388
  const token = sign({ role: "admin", exp }, secret);
33230
33389
  res.setHeader(
@@ -33270,6 +33429,191 @@ export default function handler(req, res) {
33270
33429
  }
33271
33430
  res.status(200).json({ ok: true });
33272
33431
  }
33432
+ `;
33433
+ exports2.API_ADMIN_PASSWORD_JS = `import bcrypt from "bcryptjs";
33434
+ import {
33435
+ createHmac,
33436
+ timingSafeEqual,
33437
+ createHash,
33438
+ createCipheriv,
33439
+ createDecipheriv,
33440
+ randomBytes,
33441
+ } from "node:crypto";
33442
+ import { list, put } from "@vercel/blob";
33443
+
33444
+ export const config = { runtime: "nodejs" };
33445
+
33446
+ const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
33447
+ const AUTH_KEY = PFX + "admin-auth.json";
33448
+
33449
+ const MIN_LENGTH = 10;
33450
+
33451
+
33452
+ // admin-auth.json holds a bcrypt hash and lives in a PUBLIC Blob store at a
33453
+ // non-random path, so treat its URL as readable by anyone who learns it (the
33454
+ // store hostname leaks via photo URLs; only the per-site UUID stands between an
33455
+ // outsider and this file). A leaked bcrypt hash is crackable offline where our
33456
+ // login rate limit gives zero protection \u2014 so the contents are encrypted with
33457
+ // AES-256-GCM under a key derived from AUTH_ENC_KEY, which only ever exists as
33458
+ // a server-side env var. A public read then yields opaque bytes.
33459
+ function authKey() {
33460
+ const material = process.env.AUTH_ENC_KEY || "";
33461
+ if (!material) return null;
33462
+ return createHash("sha256").update("sproutkit-admin-auth:" + material).digest();
33463
+ }
33464
+
33465
+ function sealAuth(obj) {
33466
+ const key = authKey();
33467
+ const plain = JSON.stringify(obj);
33468
+ if (!key) return JSON.stringify({ v: 0, plain: obj });
33469
+ const iv = randomBytes(12);
33470
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
33471
+ const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
33472
+ return JSON.stringify({
33473
+ v: 1,
33474
+ iv: iv.toString("base64"),
33475
+ tag: cipher.getAuthTag().toString("base64"),
33476
+ ct: ct.toString("base64"),
33477
+ });
33478
+ }
33479
+
33480
+ function openAuth(raw) {
33481
+ if (!raw || typeof raw !== "object") return null;
33482
+ // v0 = written before encryption shipped (or with no key available).
33483
+ if (raw.v === 0 && raw.plain) return raw.plain;
33484
+ if (raw.v !== 1) return null;
33485
+ const key = authKey();
33486
+ if (!key) return null;
33487
+ try {
33488
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
33489
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
33490
+ const out = Buffer.concat([
33491
+ decipher.update(Buffer.from(raw.ct, "base64")),
33492
+ decipher.final(),
33493
+ ]).toString("utf8");
33494
+ return JSON.parse(out);
33495
+ } catch {
33496
+ // Wrong key or tampered payload \u2014 fall back to the env-var password rather
33497
+ // than locking the owner out.
33498
+ return null;
33499
+ }
33500
+ }
33501
+
33502
+ function verifySession(req) {
33503
+ const cookie = req.headers.cookie || "";
33504
+ const match = cookie.match(/sk_admin=([^;]+)/);
33505
+ if (!match) return false;
33506
+ const secret = process.env.SESSION_SECRET;
33507
+ if (!secret) return false;
33508
+ const [payload, sig] = match[1].split(".");
33509
+ if (!payload || !sig) return false;
33510
+ try {
33511
+ const expected = createHmac("sha256", secret).update(payload).digest();
33512
+ const provided = Buffer.from(sig.replace(/-/g, "+").replace(/_/g, "/"), "base64");
33513
+ if (expected.length !== provided.length) return false;
33514
+ if (!timingSafeEqual(expected, provided)) return false;
33515
+ const data = JSON.parse(
33516
+ Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
33517
+ );
33518
+ return typeof data.exp === "number" && data.exp > Date.now() / 1000;
33519
+ } catch {
33520
+ return false;
33521
+ }
33522
+ }
33523
+
33524
+ function readBody(req) {
33525
+ return new Promise((resolve, reject) => {
33526
+ const chunks = [];
33527
+ req.on("data", (c) => chunks.push(c));
33528
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
33529
+ req.on("error", reject);
33530
+ });
33531
+ }
33532
+
33533
+ async function currentHash() {
33534
+ try {
33535
+ const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
33536
+ const target = blobs.find((b) => b.pathname === AUTH_KEY);
33537
+ if (target) {
33538
+ const res = await fetch(target.url, { cache: "no-store" });
33539
+ if (res.ok) {
33540
+ const j = openAuth(await res.json());
33541
+ if (j && typeof j.passwordHash === "string" && j.passwordHash) return j.passwordHash;
33542
+ }
33543
+ }
33544
+ } catch {
33545
+ // fall through to the env var
33546
+ }
33547
+ return process.env.ADMIN_PASSWORD_HASH || "";
33548
+ }
33549
+
33550
+ export default async function handler(req, res) {
33551
+ if (req.method !== "POST") {
33552
+ res.status(405).json({ error: "method_not_allowed" });
33553
+ return;
33554
+ }
33555
+ // Must already be logged in AND know the current password \u2014 a stolen session
33556
+ // cookie alone must not be enough to lock the real owner out.
33557
+ if (!verifySession(req)) {
33558
+ res.status(401).json({ error: "unauthorized" });
33559
+ return;
33560
+ }
33561
+ let body = req.body;
33562
+ if (typeof body === "string") {
33563
+ try { body = JSON.parse(body); } catch { body = null; }
33564
+ } else if (!body) {
33565
+ try {
33566
+ const raw = await readBody(req);
33567
+ body = raw ? JSON.parse(raw) : {};
33568
+ } catch {
33569
+ body = {};
33570
+ }
33571
+ }
33572
+ const currentPassword = String((body && body.currentPassword) || "");
33573
+ const newPassword = String((body && body.newPassword) || "");
33574
+
33575
+ if (newPassword.length < MIN_LENGTH) {
33576
+ res.status(400).json({ error: "too_short", minLength: MIN_LENGTH });
33577
+ return;
33578
+ }
33579
+ if (newPassword.length > 200) {
33580
+ res.status(400).json({ error: "too_long" });
33581
+ return;
33582
+ }
33583
+ if (newPassword === currentPassword) {
33584
+ res.status(400).json({ error: "unchanged" });
33585
+ return;
33586
+ }
33587
+
33588
+ const existing = await currentHash();
33589
+ if (!existing) {
33590
+ res.status(500).json({ error: "not_configured" });
33591
+ return;
33592
+ }
33593
+ const ok = await bcrypt.compare(currentPassword, existing);
33594
+ if (!ok) {
33595
+ await new Promise((r) => setTimeout(r, 300));
33596
+ res.status(401).json({ error: "wrong_current_password" });
33597
+ return;
33598
+ }
33599
+
33600
+ const salt = await bcrypt.genSalt(10);
33601
+ const passwordHash = await bcrypt.hash(newPassword, salt);
33602
+ try {
33603
+ // clientChanged is read (unencrypted) by nothing sensitive \u2014 it only tells
33604
+ // the agency "your stored copy is no longer the live password".
33605
+ await put(AUTH_KEY, sealAuth({ passwordHash, updatedAt: new Date().toISOString(), clientChanged: true }), {
33606
+ access: "public",
33607
+ addRandomSuffix: false,
33608
+ allowOverwrite: true,
33609
+ contentType: "application/json",
33610
+ });
33611
+ } catch (e) {
33612
+ res.status(500).json({ error: "save_failed", detail: (e && e.message) || "unknown" });
33613
+ return;
33614
+ }
33615
+ res.status(200).json({ ok: true });
33616
+ }
33273
33617
  `;
33274
33618
  exports2.API_ADMIN_UPLOAD_JS = `import { put } from "@vercel/blob";
33275
33619
 
@@ -33990,6 +34334,98 @@ export default async function handler(req, res) {
33990
34334
  );
33991
34335
  }
33992
34336
 
34337
+ function TabBar(props) {
34338
+ return h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12 } },
34339
+ props.tabs.map(function (t) {
34340
+ var active = props.section[0] === t.id;
34341
+ return h("button", {
34342
+ key: t.id,
34343
+ onClick: function () { props.section[1](t.id); },
34344
+ style: {
34345
+ background: active ? "var(--accent)" : "transparent",
34346
+ color: active ? "#000" : "var(--text)",
34347
+ border: "1px solid " + (active ? "var(--accent)" : "var(--border)"),
34348
+ borderRadius: 999, padding: "8px 16px",
34349
+ font: "inherit", fontWeight: 500, fontSize: 13, cursor: "pointer"
34350
+ }
34351
+ }, t.label);
34352
+ })
34353
+ );
34354
+ }
34355
+
34356
+ // Lets the owner replace the password we generated for them. Without this the
34357
+ // agency permanently knows the client's login, which isn't acceptable for a
34358
+ // panel holding customer names and phone numbers.
34359
+ function PasswordPanel() {
34360
+ var cur = useState(""); var next = useState(""); var again = useState("");
34361
+ var busy = useState(false); var msg = useState(null); var err = useState("");
34362
+
34363
+ async function submit(e) {
34364
+ e.preventDefault();
34365
+ err[1](""); msg[1](null);
34366
+ if (next[0].length < 10) { err[1]("New password must be at least 10 characters."); return; }
34367
+ if (next[0] !== again[0]) { err[1]("The two new passwords don't match."); return; }
34368
+ busy[1](true);
34369
+ try {
34370
+ var res = await fetch("/api/admin/password", {
34371
+ method: "POST",
34372
+ credentials: "same-origin",
34373
+ headers: { "Content-Type": "application/json" },
34374
+ body: JSON.stringify({ currentPassword: cur[0], newPassword: next[0] })
34375
+ });
34376
+ var data = null;
34377
+ try { data = await res.json(); } catch (_) { data = null; }
34378
+ if (res.ok) {
34379
+ msg[1]("Password changed. Use the new one next time you sign in.");
34380
+ cur[1](""); next[1](""); again[1]("");
34381
+ } else if (res.status === 401) {
34382
+ err[1]((data && data.error) === "wrong_current_password"
34383
+ ? "That's not your current password."
34384
+ : "Your session expired \u2014 reload and sign in again.");
34385
+ } else if (data && data.error === "too_short") {
34386
+ err[1]("New password must be at least " + (data.minLength || 10) + " characters.");
34387
+ } else if (data && data.error === "unchanged") {
34388
+ err[1]("That's already your password.");
34389
+ } else {
34390
+ err[1]("Couldn't change it (" + res.status + "). Try again.");
34391
+ }
34392
+ } catch (_) {
34393
+ err[1]("Network error \u2014 check your connection and try again.");
34394
+ }
34395
+ busy[1](false);
34396
+ }
34397
+
34398
+ var field = function (label, st, auto) {
34399
+ return h("label", { style: { display: "block", marginBottom: 12 } },
34400
+ h("div", { style: { fontSize: 12, color: "var(--muted)", marginBottom: 4 } }, label),
34401
+ h("input", {
34402
+ type: "password", value: st[0], autoComplete: auto,
34403
+ onChange: function (e) { st[1](e.target.value); },
34404
+ style: { width: "100%", maxWidth: 360 }
34405
+ })
34406
+ );
34407
+ };
34408
+
34409
+ return h("div", null,
34410
+ h("div", { className: "bar" },
34411
+ h("div", null,
34412
+ h("div", { style: { fontSize: 15, fontWeight: 600 } }, "Password"),
34413
+ h("div", { style: { fontSize: 12, color: "var(--muted)" } },
34414
+ "Change the password you were given")
34415
+ )
34416
+ ),
34417
+ h("form", { onSubmit: submit, style: { maxWidth: 400 } },
34418
+ field("Current password", cur, "current-password"),
34419
+ field("New password", next, "new-password"),
34420
+ field("Repeat new password", again, "new-password"),
34421
+ err[0] ? h("div", { style: { color: "#b91c1c", fontSize: 13, margin: "8px 0" } }, err[0]) : null,
34422
+ msg[0] ? h("div", { style: { color: "var(--accent)", fontSize: 13, margin: "8px 0" } }, msg[0]) : null,
34423
+ h("button", { type: "submit", className: "primary", disabled: busy[0] },
34424
+ busy[0] ? "Saving\u2026" : "Change password")
34425
+ )
34426
+ );
34427
+ }
34428
+
33993
34429
  function App() {
33994
34430
  var authed = useState(false);
33995
34431
  var section = useState("content");
@@ -34003,33 +34439,29 @@ export default async function handler(req, res) {
34003
34439
  // Bookings-only site: no Site content tab to switch to, and the public
34004
34440
  // booking page is the site root rather than /book.
34005
34441
  if (!ctl.available || !ctl.content) {
34006
- return h("div", { className: "app" }, h(BookingsPanel, { bookingPath: "/" }));
34442
+ return h("div", { className: "app" },
34443
+ h(TabBar, { tabs: [{ id: "bookings", label: "Bookings" }, { id: "password", label: "Password" }], section: section }),
34444
+ section[0] === "password"
34445
+ ? h(PasswordPanel, null)
34446
+ : h(BookingsPanel, { bookingPath: "/" })
34447
+ );
34007
34448
  }
34008
34449
 
34009
- // Top nav lets the owner flip between Site content and Bookings.
34450
+ // Top nav lets the owner flip between site content, bookings and password.
34010
34451
  return h("div", { className: "app" },
34011
- h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12 } },
34012
- [
34452
+ h(TabBar, {
34453
+ tabs: [
34013
34454
  { id: "content", label: "Site content" },
34014
- { id: "bookings", label: "Bookings" }
34015
- ].map(function (t) {
34016
- var active = section[0] === t.id;
34017
- return h("button", {
34018
- key: t.id,
34019
- onClick: function () { section[1](t.id); },
34020
- style: {
34021
- background: active ? "var(--accent)" : "transparent",
34022
- color: active ? "#000" : "var(--text)",
34023
- border: "1px solid " + (active ? "var(--accent)" : "var(--border)"),
34024
- borderRadius: 999, padding: "8px 16px",
34025
- font: "inherit", fontWeight: 500, fontSize: 13, cursor: "pointer"
34026
- }
34027
- }, t.label);
34028
- })
34029
- ),
34455
+ { id: "bookings", label: "Bookings" },
34456
+ { id: "password", label: "Password" }
34457
+ ],
34458
+ section: section
34459
+ }),
34030
34460
  section[0] === "content"
34031
34461
  ? h(Editor, { content: ctl.content, setContent: ctl.setContent })
34032
- : h(BookingsPanel, null)
34462
+ : section[0] === "password"
34463
+ ? h(PasswordPanel, null)
34464
+ : h(BookingsPanel, null)
34033
34465
  );
34034
34466
  }
34035
34467
 
@@ -34057,7 +34489,19 @@ export default async function handler(req, res) {
34057
34489
  "tidy",
34058
34490
  "warm",
34059
34491
  "wise",
34060
- "zesty"
34492
+ "zesty",
34493
+ "bold",
34494
+ "crisp",
34495
+ "fair",
34496
+ "glad",
34497
+ "jolly",
34498
+ "keen",
34499
+ "merry",
34500
+ "noble",
34501
+ "plucky",
34502
+ "spry",
34503
+ "tough",
34504
+ "vivid"
34061
34505
  ];
34062
34506
  var ANIMALS = [
34063
34507
  "badger",
@@ -34079,13 +34523,29 @@ export default async function handler(req, res) {
34079
34523
  "wolf",
34080
34524
  "zebra",
34081
34525
  "boar",
34082
- "crane"
34526
+ "crane",
34527
+ "bison",
34528
+ "dingo",
34529
+ "eagle",
34530
+ "falcon",
34531
+ "gecko",
34532
+ "ibex",
34533
+ "jackal",
34534
+ "kestrel",
34535
+ "marten",
34536
+ "osprey",
34537
+ "panda",
34538
+ "quail"
34083
34539
  ];
34084
34540
  function generateFriendlyPassword() {
34085
- const a = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
34086
- const b = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
34087
- const n = 10 + Math.floor(Math.random() * 89);
34088
- return `${a}-${b}-${n}`;
34541
+ const pick = (arr) => arr[(0, node_crypto_1.randomInt)(arr.length)];
34542
+ return [
34543
+ pick(ADJECTIVES),
34544
+ pick(ANIMALS),
34545
+ pick(ADJECTIVES),
34546
+ pick(ANIMALS),
34547
+ String(100 + (0, node_crypto_1.randomInt)(900))
34548
+ ].join("-");
34089
34549
  }
34090
34550
  async function hashPassword(plain) {
34091
34551
  const bcrypt = await Promise.resolve().then(() => __importStar(require_umd()));
@@ -79825,6 +80285,15 @@ var require_builder = __commonJS({
79825
80285
  db2.run("UPDATE leads SET site_uid = ? WHERE id = ?", [uid, leadId]);
79826
80286
  return uid;
79827
80287
  }
80288
+ function authEncKeyFor(leadId) {
80289
+ const db2 = (0, db_1.getDb)();
80290
+ const row = db2.get("SELECT auth_enc_key FROM leads WHERE id = ?", [leadId]);
80291
+ if (row?.auth_enc_key)
80292
+ return row.auth_enc_key;
80293
+ const key = (0, crypto_1.randomUUID)() + (0, crypto_1.randomUUID)();
80294
+ db2.run("UPDATE leads SET auth_enc_key = ? WHERE id = ?", [key, leadId]);
80295
+ return key;
80296
+ }
79828
80297
  var STOCK_HOSTS = [
79829
80298
  "unsplash.com",
79830
80299
  "images.unsplash.com",
@@ -80572,6 +81041,7 @@ export default defineConfig({ plugins: [react()] });`;
80572
81041
  SITE_ID: cmsSiteUid,
80573
81042
  ADMIN_PASSWORD_HASH: adminHash,
80574
81043
  SESSION_SECRET: sessionSecret,
81044
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
80575
81045
  BUSINESS_NAME: lead.name
80576
81046
  });
80577
81047
  const vercelJson = JSON.stringify({
@@ -80585,6 +81055,7 @@ export default defineConfig({ plugins: [react()] });`;
80585
81055
  { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" },
80586
81056
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
80587
81057
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81058
+ { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
80588
81059
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" },
80589
81060
  { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" },
80590
81061
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" }
@@ -80682,7 +81153,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
80682
81153
  const viteConfigCms = `import { defineConfig } from 'vite';
80683
81154
  import react from '@vitejs/plugin-react';
80684
81155
  export default defineConfig({ plugins: [react()] });`;
80685
- filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81156
+ filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
80686
81157
  if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
80687
81158
  throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
80688
81159
  }
@@ -80692,6 +81163,7 @@ export default defineConfig({ plugins: [react()] });`;
80692
81163
  BLOB_READ_WRITE_TOKEN: blobToken,
80693
81164
  ADMIN_PASSWORD_HASH: adminHash,
80694
81165
  SESSION_SECRET: sessionSecret,
81166
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
80695
81167
  BUSINESS_NAME: lead.name
80696
81168
  };
80697
81169
  if (existingUid)
@@ -80790,7 +81262,8 @@ export default defineConfig({ plugins: [react()] });`;
80790
81262
  const adminEnv = {
80791
81263
  BLOB_READ_WRITE_TOKEN: blobToken,
80792
81264
  ADMIN_PASSWORD_HASH: adminHash,
80793
- SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)()
81265
+ SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81266
+ AUTH_ENC_KEY: authEncKeyFor(lead.id)
80794
81267
  };
80795
81268
  if (bookingExistingUid)
80796
81269
  adminEnv.SITE_ID = bookingExistingUid;
@@ -80837,6 +81310,7 @@ export default defineConfig({ plugins: [react()] });`;
80837
81310
  { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" },
80838
81311
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
80839
81312
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81313
+ { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
80840
81314
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }
80841
81315
  );
80842
81316
  onProgress("Deploying\u2026");
@@ -80882,7 +81356,8 @@ export default defineConfig({ plugins: [react()] });`;
80882
81356
  SITE_ID: standaloneSiteUid,
80883
81357
  // Admin panel auth.
80884
81358
  ADMIN_PASSWORD_HASH: adminHash,
80885
- SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)()
81359
+ SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81360
+ AUTH_ENC_KEY: authEncKeyFor(lead.id)
80886
81361
  });
80887
81362
  const files = [
80888
81363
  { file: "index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
@@ -80895,6 +81370,7 @@ export default defineConfig({ plugins: [react()] });`;
80895
81370
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
80896
81371
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
80897
81372
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81373
+ { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
80898
81374
  // Serve /admin without a trailing-slash redirect dance.
80899
81375
  {
80900
81376
  file: "vercel.json",
@@ -81076,11 +81552,12 @@ export default defineConfig({ plugins: [react()] });`,
81076
81552
  ]
81077
81553
  }),
81078
81554
  encoding: "utf-8"
81079
- }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81555
+ }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81080
81556
  const env = {
81081
81557
  BLOB_READ_WRITE_TOKEN: blobToken,
81082
81558
  ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
81083
81559
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81560
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
81084
81561
  BUSINESS_NAME: lead.name
81085
81562
  };
81086
81563
  if (siteUid)
@@ -93969,7 +94446,10 @@ Interested?`;
93969
94446
  const status = input.status;
93970
94447
  const city = input.city;
93971
94448
  const limit = Math.min(Number(input.limit ?? 25), 100);
93972
- let sql = "SELECT id, name, status, type, city, phone, site_url, website, lead_kind, booking_status, booking_platform, booking_url FROM leads";
94449
+ let sql = `SELECT id, name, status, type, city, phone, email, site_url, website, lead_kind,
94450
+ booking_status, booking_platform, booking_url, admin_url, custom_domain,
94451
+ handed_over_at
94452
+ FROM leads`;
93973
94453
  const conds = [];
93974
94454
  const params = [];
93975
94455
  if (status) {
@@ -95356,7 +95836,7 @@ var require_package2 = __commonJS({
95356
95836
  "package.json"(exports2, module2) {
95357
95837
  module2.exports = {
95358
95838
  name: "sproutkit-code",
95359
- version: "1.4.0",
95839
+ version: "1.5.1",
95360
95840
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
95361
95841
  bin: {
95362
95842
  sproutkit: "./dist/index.js"
@@ -96486,10 +96966,64 @@ async function cmdList(argv) {
96486
96966
  say(c.dim(' No leads yet. Try: sproutkit find "barbers in <area>"'));
96487
96967
  return;
96488
96968
  }
96969
+ renderLeadTable(rows);
96970
+ }
96971
+ var STAGE_LABEL = {
96972
+ discovered: "New",
96973
+ site_built: "Built",
96974
+ contacted: "Contacted",
96975
+ replied: "Replied",
96976
+ won: "Won",
96977
+ dead: "Dead"
96978
+ };
96979
+ function cell(text, width) {
96980
+ const t = text ?? "";
96981
+ if (t.length === width) return t;
96982
+ if (t.length < width) return t + " ".repeat(width - t.length);
96983
+ return width <= 1 ? t.slice(0, width) : t.slice(0, width - 1) + "\u2026";
96984
+ }
96985
+ function renderLeadTable(rows) {
96986
+ const term = process.stdout.columns && process.stdout.columns > 40 ? process.stdout.columns : 100;
96987
+ const idW = Math.max(3, ...rows.map((r) => String(r.id).length + 1));
96988
+ const stageW = Math.max(5, ...rows.map((r) => (STAGE_LABEL[r.status] ?? r.status).length));
96989
+ const placeW = Math.min(14, Math.max(5, ...rows.map((r) => (r.city || "").length)));
96990
+ const fixed = 2 + idW + 2 + stageW + 2 + placeW + 2;
96991
+ const nameW = Math.max(16, Math.min(34, term - fixed - 24));
96489
96992
  say();
96993
+ say(
96994
+ " " + c.dim(
96995
+ cell("ID", idW) + " " + cell("BUSINESS", nameW) + " " + cell("STAGE", stageW) + " " + cell("AREA", placeW) + " WHAT THEY HAVE"
96996
+ )
96997
+ );
96998
+ say(" " + c.dim("\u2500".repeat(Math.min(term - 4, idW + nameW + stageW + placeW + 23))));
96490
96999
  for (const r of rows) {
96491
- const site = r.site_url ? c.dim(` ${r.site_url}`) : "";
96492
- say(` ${c.greenBold("#" + r.id)} ${c.bold(r.name)} ${c.dim(`\xB7 ${r.status} \xB7 ${r.type}, ${r.city}`)}${site}`);
97000
+ const stage = STAGE_LABEL[r.status] ?? r.status;
97001
+ const stageColoured = r.status === "won" ? c.greenBold(cell(stage, stageW)) : r.status === "site_built" || r.status === "replied" ? c.green(cell(stage, stageW)) : r.status === "dead" ? c.dim(cell(stage, stageW)) : c.dim(cell(stage, stageW));
97002
+ const short = (u) => u.replace(/^https?:\/\//, "").replace(/\/$/, "");
97003
+ const assets = [];
97004
+ if (r.custom_domain) assets.push(c.cyan(r.custom_domain));
97005
+ else if (r.site_url) assets.push(short(r.site_url));
97006
+ if (r.booking_url) assets.push(r.site_url ? c.dim("+ booking") : short(r.booking_url));
97007
+ if (r.admin_url) assets.push(c.dim("+ admin"));
97008
+ if (!r.site_url && !r.booking_url && r.website) {
97009
+ assets.push(c.dim("own site: " + short(r.website)));
97010
+ }
97011
+ if (assets.length === 0) assets.push(c.dim("\u2014"));
97012
+ if (r.handed_over_at) assets.push(c.greenBold("\u2713 delivered"));
97013
+ say(
97014
+ " " + c.dim(cell("#" + r.id, idW)) + " " + c.bold(cell(r.name, nameW)) + " " + stageColoured + " " + c.dim(cell(r.city || "\u2014", placeW)) + " " + assets.join(c.dim(" \xB7 "))
97015
+ );
97016
+ }
97017
+ const counts = rows.reduce((acc, r) => {
97018
+ acc[r.status] = (acc[r.status] ?? 0) + 1;
97019
+ return acc;
97020
+ }, {});
97021
+ const order = ["discovered", "site_built", "contacted", "replied", "won", "dead"];
97022
+ const summary = order.filter((k) => counts[k]).map((k) => `${counts[k]} ${(STAGE_LABEL[k] ?? k).toLowerCase()}`).join(c.dim(" \xB7 "));
97023
+ say();
97024
+ say(" " + c.dim(`${rows.length} lead${rows.length === 1 ? "" : "s"} shown \u2014 `) + summary);
97025
+ if (rows.length >= 25) {
97026
+ say(c.dim(" More may exist \u2014 use --limit 100, or --status/--city to narrow."));
96493
97027
  }
96494
97028
  say();
96495
97029
  }
@@ -96756,6 +97290,7 @@ async function cmdHandover(argv) {
96756
97290
  if (r.admin) {
96757
97291
  say(` Admin panel: ${r.admin.url}`);
96758
97292
  say(` Password: ${c.bold(r.admin.password)}`);
97293
+ say(c.dim(" (unless the client has already changed it in the panel)"));
96759
97294
  }
96760
97295
  if (r.bookingUrl) say(` Booking page: ${r.bookingUrl}`);
96761
97296
  if (r.bookingAdmin) {
@@ -96867,6 +97402,8 @@ function sayAdminHandover(adminUrl, adminPassword) {
96867
97402
  say(` Admin panel: ${adminUrl}`);
96868
97403
  say(` Password: ${adminPassword}`);
96869
97404
  say(c.dim(" Send these to the client \u2014 they manage bookings and content there."));
97405
+ say(c.dim(" They can change this password under Password in the panel; after that"));
97406
+ say(c.dim(" your copy is out of date and only they know the live one."));
96870
97407
  }
96871
97408
  async function cmdBook(argv) {
96872
97409
  const { flags, rest } = parseArgs(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Sproutkit in your terminal — find leads, build sites, run outreach, all via chat.",
5
5
  "bin": {
6
6
  "sproutkit": "./dist/index.js"