@rubytech/create-sitedesk-code 0.1.340 → 0.1.341

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 (36) hide show
  1. package/dist/__tests__/known-brand-hostnames.test.js +40 -0
  2. package/dist/__tests__/neo4j-teardown.test.js +55 -0
  3. package/dist/known-brands.js +24 -0
  4. package/dist/neo4j-teardown.js +45 -0
  5. package/dist/uninstall.js +89 -40
  6. package/package.json +1 -1
  7. package/payload/platform/config/brand.json +2 -1
  8. package/payload/platform/plugins/admin/skills/platform-architecture/SKILL.md +3 -1
  9. package/payload/platform/plugins/docs/references/admin-ui.md +2 -0
  10. package/payload/server/{chunk-NE7G5GT7.js → chunk-5BG6CHGH.js} +17 -0
  11. package/payload/server/maxy-edge.js +1 -1
  12. package/payload/server/public/assets/{AdminLoginScreens-CHTeh_Vu.js → AdminLoginScreens-Brx8CmXN.js} +1 -1
  13. package/payload/server/public/assets/{AdminShell-DjoP7YoA.js → AdminShell-CHZMDX2u.js} +1 -1
  14. package/payload/server/public/assets/{Checkbox-D58GsKoQ.js → Checkbox-aePjWzRH.js} +1 -1
  15. package/payload/server/public/assets/{OperatorConversations-CDdp2nVn.css → OperatorConversations-BMIZQR9t.css} +1 -1
  16. package/payload/server/public/assets/{OperatorConversations-RmqANYz8.js → OperatorConversations-DpjPPIOp.js} +1 -1
  17. package/payload/server/public/assets/{admin-LGICBqil.js → admin-DIDvfti6.js} +1 -1
  18. package/payload/server/public/assets/{browser-CRgweVtw.js → browser-Bp5kGgyr.js} +1 -1
  19. package/payload/server/public/assets/chat-C0IWx7FL.js +1 -0
  20. package/payload/server/public/assets/{data-CttrzhfL.js → data-RsMye_06.js} +1 -1
  21. package/payload/server/public/assets/{graph-CIBba84R.js → graph-Bnsvbnkf.js} +1 -1
  22. package/payload/server/public/assets/{graph-labels-Bi0fu8Ns.js → graph-labels-jduMtwXb.js} +1 -1
  23. package/payload/server/public/assets/{operator-mwkYv8g5.js → operator-9K-TElDd.js} +1 -1
  24. package/payload/server/public/assets/page-BT9hkXHm.js +30 -0
  25. package/payload/server/public/assets/{public-CiUboUwu.js → public-DvL1Zov1.js} +1 -1
  26. package/payload/server/public/brand/sitedesk-og-image.png +0 -0
  27. package/payload/server/public/browser.html +4 -4
  28. package/payload/server/public/chat.html +5 -5
  29. package/payload/server/public/data.html +4 -4
  30. package/payload/server/public/graph.html +6 -6
  31. package/payload/server/public/index.html +6 -6
  32. package/payload/server/public/operator.html +7 -7
  33. package/payload/server/public/public.html +5 -5
  34. package/payload/server/server.js +183 -26
  35. package/payload/server/public/assets/chat-CXPRTVW7.js +0 -1
  36. package/payload/server/public/assets/page-BpxHz1N-.js +0 -30
@@ -0,0 +1,40 @@
1
+ // Drift guard: every shipped brand's hostname must appear in
2
+ // KNOWN_BRAND_HOSTNAMES, the allowlist that peerBrandPresent() matches against
3
+ // to decide whether a co-resident brand is still installed. A shipped brand
4
+ // missing from the list makes peerBrandPresent() report "no peer" while that
5
+ // brand survives, so the uninstall wipes device-wide singletons (~/.claude
6
+ // OAuth, ~/.ollama cache, shared Neo4j data, apt packages) it still depends on.
7
+ //
8
+ // The list is intentionally a SUPERSET of brands/ (it carries legacy hostnames
9
+ // and reserved slots that have no brand.json), so this is a subset check, not
10
+ // an equality check. The reverse direction is not constrained.
11
+ //
12
+ // Runs via Node's built-in test runner (the codebase convention — see
13
+ // peer-brand-detect.test.ts). Path resolution mirrors base-toolchain-deps.test.ts.
14
+ import test from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { readdirSync, readFileSync, existsSync } from "node:fs";
17
+ import { fileURLToPath } from "node:url";
18
+ import { dirname, resolve, join } from "node:path";
19
+ import { KNOWN_BRAND_HOSTNAMES } from "../known-brands.js";
20
+ // dist/__tests__/known-brand-hostnames.test.js → maxy-code/brands
21
+ const here = dirname(fileURLToPath(import.meta.url));
22
+ const BRANDS_DIR = resolve(here, "../../../../brands");
23
+ function shippedBrandHostnames() {
24
+ return readdirSync(BRANDS_DIR, { withFileTypes: true })
25
+ .filter((e) => e.isDirectory())
26
+ .map((e) => join(BRANDS_DIR, e.name, "brand.json"))
27
+ .filter((p) => existsSync(p))
28
+ .map((p) => JSON.parse(readFileSync(p, "utf-8")).hostname);
29
+ }
30
+ test("brands/ directory is discoverable from the test location", () => {
31
+ assert.ok(existsSync(BRANDS_DIR), `brands dir not found at ${BRANDS_DIR}`);
32
+ assert.ok(shippedBrandHostnames().length > 0, "no shipped brand.json hostnames found");
33
+ });
34
+ test("every shipped brand hostname is in KNOWN_BRAND_HOSTNAMES", () => {
35
+ const allow = new Set(KNOWN_BRAND_HOSTNAMES);
36
+ for (const hostname of shippedBrandHostnames()) {
37
+ assert.ok(allow.has(hostname), `brands/ ships hostname "${hostname}" but it is absent from KNOWN_BRAND_HOSTNAMES; ` +
38
+ `peerBrandPresent() would not detect a co-resident "${hostname}" install`);
39
+ }
40
+ });
@@ -0,0 +1,55 @@
1
+ // Contract grid for resolveDedicatedNeo4jTeardown — the pure decision behind
2
+ // the uninstall's dedicated-Neo4j decommission. The wrapper in uninstall.ts
3
+ // owns fs/systemctl; this suite is decision-only, no I/O.
4
+ //
5
+ // The load-bearing guarantee: the dedicated-removal path NEVER targets the
6
+ // bare `neo4j` service / port 7687, including when the brand port is
7
+ // unreadable (undefined). Runs via Node's built-in test runner (the codebase
8
+ // convention — see peer-brand-detect.test.ts).
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { resolveDedicatedNeo4jTeardown, SHARED_NEO4J_PORT } from "../neo4j-teardown.js";
12
+ const HOST = "sitedesk-code";
13
+ test("dedicated port → full teardown plan with install-parity names", () => {
14
+ const plan = resolveDedicatedNeo4jTeardown({ brandHostname: HOST, neo4jPort: 7689 });
15
+ assert.notEqual(plan, null);
16
+ // Names derive from BRAND.hostname exactly as setupDedicatedNeo4j creates them.
17
+ assert.deepEqual(plan, {
18
+ service: "neo4j-sitedesk-code",
19
+ unitFile: "/etc/systemd/system/neo4j-sitedesk-code.service",
20
+ confDir: "/etc/neo4j-sitedesk-code",
21
+ dataDir: "/var/lib/neo4j-sitedesk-code",
22
+ logDir: "/var/log/neo4j-sitedesk-code",
23
+ });
24
+ });
25
+ test("dedicated plan never names the shared `neo4j` service", () => {
26
+ const plan = resolveDedicatedNeo4jTeardown({ brandHostname: HOST, neo4jPort: 7689 });
27
+ assert.notEqual(plan, null);
28
+ assert.notEqual(plan.service, "neo4j");
29
+ for (const p of [plan.unitFile, plan.confDir, plan.dataDir, plan.logDir]) {
30
+ // No teardown path is the shared apt-managed location.
31
+ assert.notEqual(p, "/var/lib/neo4j");
32
+ assert.notEqual(p, "/etc/neo4j");
33
+ assert.notEqual(p, "/var/log/neo4j");
34
+ assert.notEqual(p, "/etc/systemd/system/neo4j.service");
35
+ }
36
+ });
37
+ test("shared port 7687 → null (shared path, never the dedicated removal)", () => {
38
+ assert.equal(resolveDedicatedNeo4jTeardown({ brandHostname: HOST, neo4jPort: SHARED_NEO4J_PORT }), null);
39
+ });
40
+ test("unreadable brand port (undefined) → null, never selects shared", () => {
41
+ // The defect this guards: a port-read failure must NOT fall through to the
42
+ // shared `neo4j`/7687. null tells the caller to take the shared path only
43
+ // when the brand is genuinely shared, never on read failure of a dedicated brand.
44
+ assert.equal(resolveDedicatedNeo4jTeardown({ brandHostname: HOST, neo4jPort: undefined }), null);
45
+ });
46
+ test("empty hostname → null (cannot form a dedicated unit)", () => {
47
+ assert.equal(resolveDedicatedNeo4jTeardown({ brandHostname: "", neo4jPort: 7689 }), null);
48
+ });
49
+ test("a different dedicated port still yields a hostname-derived plan", () => {
50
+ // Unit/dir names depend on hostname, not the port value — a forced
51
+ // --neo4j-port that differs from brand.json still tears down the same unit.
52
+ const plan = resolveDedicatedNeo4jTeardown({ brandHostname: "realagent-code", neo4jPort: 7700 });
53
+ assert.equal(plan.service, "neo4j-realagent-code");
54
+ assert.equal(plan.dataDir, "/var/lib/neo4j-realagent-code");
55
+ });
@@ -0,0 +1,24 @@
1
+ // Allowlist of brand hostnames in the Maxy ecosystem. peerBrandPresent() in
2
+ // uninstall.ts matches systemd unit filenames (`<hostname>.service` and
3
+ // `<hostname>-edge.service`) against this list to decide whether a co-resident
4
+ // brand is still installed — the gate on every device-wide teardown step
5
+ // (shared ~/.claude / ~/.ollama wipe, shared Neo4j data wipe, apt purge,
6
+ // Ollama/Samba teardown). Matching only these filenames, not any stray
7
+ // `.service` file, keeps stale units and unrelated user services from counting
8
+ // as peer evidence.
9
+ //
10
+ // This list is a SUPERSET of the brands shipped under `brands/`: `maxy` and
11
+ // `realagent` are legacy hostnames, `maxy-2/3/4` are reserved slots. Every
12
+ // hostname under `brands/*/brand.json` MUST appear here, or peerBrandPresent()
13
+ // fails to detect that brand as a peer. known-brand-hostnames.test.ts enforces
14
+ // that subset relationship and fails when a new shipped brand is missing.
15
+ export const KNOWN_BRAND_HOSTNAMES = [
16
+ "maxy",
17
+ "maxy-code",
18
+ "realagent",
19
+ "realagent-code",
20
+ "sitedesk-code",
21
+ "maxy-2",
22
+ "maxy-3",
23
+ "maxy-4",
24
+ ];
@@ -0,0 +1,45 @@
1
+ // Pure dedicated-Neo4j teardown resolution. Extracted from uninstall.ts so the
2
+ // "does this brand run a dedicated Neo4j, and what does its full decommission
3
+ // target?" decision can be unit-tested with concrete inputs, no fs/systemctl.
4
+ // Mirrors the peer-brand-detect.ts pattern: inputs in, plan out, no I/O.
5
+ //
6
+ // The uninstaller wraps this with the privileged stop/disable/rm and the
7
+ // daemon-reload; this module owns only the naming + the shared-instance guard.
8
+ /** The apt-installed shared Neo4j binds this bolt port. A brand on this port
9
+ * is NOT dedicated — it shares the device-wide instance. */
10
+ export const SHARED_NEO4J_PORT = 7687;
11
+ /**
12
+ * Resolve the dedicated-Neo4j teardown plan for a brand, or `null` when the
13
+ * brand does not run a dedicated instance.
14
+ *
15
+ * Returns `null` — i.e. the dedicated-removal path must NOT act, and the caller
16
+ * falls back to its shared handling — in every case where targeting a dedicated
17
+ * unit would be wrong or unsafe:
18
+ * - `neo4jPort === undefined`: the port is unreadable. A read failure must
19
+ * never select the shared `neo4j`/7687 service; returning null keeps the
20
+ * dedicated path inert rather than guessing.
21
+ * - `neo4jPort === SHARED_NEO4J_PORT`: the brand genuinely shares 7687.
22
+ * - empty `brandHostname`: no hostname means no `neo4j-<hostname>` unit can
23
+ * exist; refuse rather than form a degenerate `neo4j-` name.
24
+ *
25
+ * When a plan IS returned, `service` is always `neo4j-<hostname>` and can never
26
+ * equal the bare `neo4j` — the hard guard against ever stopping or deleting the
27
+ * shared instance.
28
+ */
29
+ export function resolveDedicatedNeo4jTeardown(args) {
30
+ const { brandHostname, neo4jPort } = args;
31
+ if (neo4jPort === undefined)
32
+ return null;
33
+ if (neo4jPort === SHARED_NEO4J_PORT)
34
+ return null;
35
+ if (!brandHostname)
36
+ return null;
37
+ const service = `neo4j-${brandHostname}`;
38
+ return {
39
+ service,
40
+ unitFile: `/etc/systemd/system/${service}.service`,
41
+ confDir: `/etc/neo4j-${brandHostname}`,
42
+ dataDir: `/var/lib/neo4j-${brandHostname}`,
43
+ logDir: `/var/log/neo4j-${brandHostname}`,
44
+ };
45
+ }
package/dist/uninstall.js CHANGED
@@ -4,6 +4,8 @@ import { resolve, join, dirname } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { createInterface } from "node:readline";
6
6
  import { removeBrandStanza, hasAnyBrandStanza } from "./samba-provision.js";
7
+ import { resolveDedicatedNeo4jTeardown } from "./neo4j-teardown.js";
8
+ import { KNOWN_BRAND_HOSTNAMES } from "./known-brands.js";
7
9
  const HOME = homedir();
8
10
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
9
11
  // Brand manifest — read from payload to derive brand-specific installation paths.
@@ -97,13 +99,6 @@ function commandExists(cmd) {
97
99
  export function isMaxyInstalled() {
98
100
  return existsSync(INSTALL_DIR);
99
101
  }
100
- /** Known brand hostnames in the Maxy ecosystem. Each brand ships a main unit
101
- * (`<hostname>.service`) and a per-brand edge unit
102
- * (`<hostname>-edge.service`). Peer detection matches only these filenames, not any stray
103
- * `.service` file — stale units, gnome-keyring disable markers, and unrelated
104
- * user services are not peer evidence. When a third brand is
105
- * added under `brands/`, append its hostname here. */
106
- const KNOWN_BRAND_HOSTNAMES = ["maxy", "maxy-code", "realagent", "realagent-code", "maxy-2", "maxy-3", "maxy-4"];
107
102
  /** Detect whether another brand is installed on this device.
108
103
  * device-wide steps (apt package purge, Ollama binary removal, apt
109
104
  * repo cleanup, ~/.claude / ~/.ollama wipes) must skip when a peer brand is
@@ -166,20 +161,26 @@ function stopServices() {
166
161
  catch {
167
162
  console.log(` ${edgeUnitShort} not running`);
168
163
  }
169
- // Stop Neo4j dedicated branded instance if this brand uses one, else shared.
170
- // Brand isolation: never stop `neo4j.service` when this brand runs
171
- // a dedicated `neo4j-<hostname>.service` stopping the shared instance would
172
- // break every other brand on the device.
173
- const neo4jPortForStop = readNeo4jPortFromEnv();
174
- const neo4jService = neo4jPortForStop !== undefined && neo4jPortForStop !== 7687
175
- ? `neo4j-${BRAND.hostname}`
176
- : "neo4j";
177
- try {
178
- spawnSync("sudo", ["systemctl", "stop", neo4jService], { stdio: "pipe", timeout: 15_000 });
179
- console.log(` Stopped ${neo4jService}`);
164
+ // Stop Neo4j. A dedicated brand's instance is stopped AND disabled here so it
165
+ // does not return at the next boot; the unit file and data/config/log dirs are
166
+ // removed in step 5 (removeNeo4jData). Hard guard: when the brand is dedicated
167
+ // the target is always `neo4j-<hostname>` the shared `neo4j`/7687 instance is
168
+ // never stopped, even if `.env` is unreadable (dedicatedNeo4jTeardown falls
169
+ // back to brand.json, never to the shared service). A genuinely shared brand
170
+ // (port 7687 / no dedicated port) stops the shared `neo4j` as before.
171
+ const neo4jTeardown = dedicatedNeo4jTeardown();
172
+ if (neo4jTeardown) {
173
+ if (privilegedSystemctl("stop", neo4jTeardown.service)) {
174
+ console.log(` Stopped ${neo4jTeardown.service}`);
175
+ }
176
+ if (privilegedSystemctl("disable", neo4jTeardown.service)) {
177
+ console.log(` Disabled ${neo4jTeardown.service}`);
178
+ }
180
179
  }
181
- catch {
182
- console.log(` ${neo4jService} not running`);
180
+ else {
181
+ if (privilegedSystemctl("stop", "neo4j")) {
182
+ console.log(" Stopped neo4j (shared instance)");
183
+ }
183
184
  }
184
185
  // Stop the cloudflared service for this brand (Task 757 — the connector now
185
186
  // runs as a supervised cloudflared-<brand>.service, not a transient scope).
@@ -438,22 +439,36 @@ function removeNeo4jData() {
438
439
  // 7687 data is skipped entirely when a peer brand is present. Dedicated
439
440
  // branded instances live at /var/lib/neo4j-<hostname>/ and are always
440
441
  // this-brand-owned by construction.
441
- const envPort = readNeo4jPortFromEnv();
442
- const isDedicated = envPort !== undefined && envPort !== 7687;
443
- const dataDir = isDedicated ? `/var/lib/neo4j-${BRAND.hostname}` : "/var/lib/neo4j";
444
- if (!isDedicated && peerBrandPresent()) {
445
- console.log(` Shared Neo4j instance on 7687 peer brand present, skipping data wipe.`);
442
+ const teardown = dedicatedNeo4jTeardown();
443
+ if (teardown) {
444
+ // Full decommission of the dedicated instance. Stop + disable already ran
445
+ // in step 1 (stopServices); here we remove every artifact the installer's
446
+ // setupDedicatedNeo4j() created unit file, config dir, data dir, log dir
447
+ // then daemon-reload so systemd forgets the unit. Removing the whole data
448
+ // dir (not just data/) also clears the install's plugins/ and import/ trees;
449
+ // the log dir is /var/log/neo4j-<hostname> (server.directories.logs), not a
450
+ // subdir of the data dir.
451
+ const paths = [teardown.unitFile, teardown.confDir, teardown.dataDir, teardown.logDir];
452
+ for (const p of paths) {
453
+ if (existsSync(p)) {
454
+ try {
455
+ shell("rm", ["-rf", p], { sudo: true });
456
+ console.log(` Removed ${p}`);
457
+ }
458
+ catch (err) {
459
+ console.log(` Failed to remove ${p}: ${err instanceof Error ? err.message : String(err)}`);
460
+ }
461
+ }
462
+ }
463
+ privilegedSystemctl("daemon-reload");
446
464
  return;
447
465
  }
448
- const paths = [`${dataDir}/data`, `${dataDir}/logs`];
449
- if (isDedicated) {
450
- // Also clean up the dedicated config dir and systemd unit — install-time
451
- // code creates both at /etc/neo4j-<hostname>/ and /etc/systemd/system/
452
- // neo4j-<hostname>.service. Shared instance owns neither.
453
- paths.push(`/etc/neo4j-${BRAND.hostname}`);
454
- paths.push(`/etc/systemd/system/neo4j-${BRAND.hostname}.service`);
466
+ // Shared instance on 7687 — skip entirely when a peer brand still depends on it.
467
+ if (peerBrandPresent()) {
468
+ console.log(` Shared Neo4j instance on 7687 peer brand present, skipping data wipe.`);
469
+ return;
455
470
  }
456
- for (const p of paths) {
471
+ for (const p of ["/var/lib/neo4j/data", "/var/lib/neo4j/logs"]) {
457
472
  if (existsSync(p)) {
458
473
  try {
459
474
  shell("rm", ["-rf", p], { sudo: true });
@@ -464,15 +479,9 @@ function removeNeo4jData() {
464
479
  }
465
480
  }
466
481
  }
467
- if (isDedicated) {
468
- try {
469
- spawnSync("sudo", ["systemctl", "daemon-reload"], { stdio: "pipe" });
470
- }
471
- catch { /* ignore */ }
472
- }
473
482
  }
474
483
  /** Read NEO4J_URI port from this brand's .env. Returns undefined when the
475
- * file is missing or malformed — caller treats that as "assume shared 7687". */
484
+ * file is missing or malformed — caller folds in the brand.json fallback. */
476
485
  function readNeo4jPortFromEnv() {
477
486
  const envPath = join(CONFIG_DIR, ".env");
478
487
  if (!existsSync(envPath))
@@ -488,6 +497,46 @@ function readNeo4jPortFromEnv() {
488
497
  return undefined;
489
498
  }
490
499
  }
500
+ /** Resolve this brand's dedicated-Neo4j teardown plan, or null when the brand
501
+ * runs the shared instance. The port is `.env` (the actual running config,
502
+ * preserving the installer's own port priority — this is where a `--neo4j-port`
503
+ * override lands) with brand.json as the robust fallback. The result is
504
+ * memoised on first call: stopServices (step 1) is the first caller and runs
505
+ * while `~/<configDir>/.env` still exists; removeAppDirs (step 4) deletes that
506
+ * `.env` before removeNeo4jData (step 5) calls again, so without the cache the
507
+ * two call sites could classify dedication differently (step 1 sees the
508
+ * override port, step 5 sees only brand.json) and orphan the dedicated dirs.
509
+ * Caching the first (env-informed) resolution keeps stop/disable and dir
510
+ * removal targeting the same instance. A read failure can never fall through
511
+ * to the shared `neo4j`/7687 service. Dedicated names are `neo4j-<hostname>`,
512
+ * matching `setupDedicatedNeo4j()`. */
513
+ let _dedicatedNeo4jTeardownResolved = false;
514
+ let _dedicatedNeo4jTeardownPlan = null;
515
+ function dedicatedNeo4jTeardown() {
516
+ if (_dedicatedNeo4jTeardownResolved)
517
+ return _dedicatedNeo4jTeardownPlan;
518
+ const neo4jPort = readNeo4jPortFromEnv() ?? BRAND.neo4jPort;
519
+ _dedicatedNeo4jTeardownPlan = resolveDedicatedNeo4jTeardown({ brandHostname: BRAND.hostname, neo4jPort });
520
+ _dedicatedNeo4jTeardownResolved = true;
521
+ return _dedicatedNeo4jTeardownPlan;
522
+ }
523
+ /** Run a privileged `systemctl <args...>` interactively so a password prompt is
524
+ * visible (the silent `stdio:"pipe"` no-op is the defect being fixed). Exit 5
525
+ * (no such unit) is an acceptable end-state — the unit is already absent, which
526
+ * keeps re-runs idempotent. Any other non-zero is a genuine failure (e.g. sudo
527
+ * auth declined) and is surfaced loudly rather than swallowed. Returns true on
528
+ * success or "already absent". Cannot delegate to shell(): shell() throws on
529
+ * any non-zero, which would abort the uninstall on an already-absent unit. */
530
+ function privilegedSystemctl(...args) {
531
+ const result = spawnSync("sudo", ["systemctl", ...args], { stdio: "inherit", timeout: 30_000 });
532
+ if (result.status === 0)
533
+ return true;
534
+ if (result.status === 5)
535
+ return true; // no such unit — already gone
536
+ const reason = result.signal ? `signal ${result.signal}` : `exit ${result.status}`;
537
+ console.error(` FAILED: sudo systemctl ${args.join(" ")} (${reason}) — privileged step did not complete`);
538
+ return false;
539
+ }
491
540
  // ---------------------------------------------------------------------------
492
541
  // Step 6: Purge system packages
493
542
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-sitedesk-code",
3
- "version": "0.1.340",
3
+ "version": "0.1.341",
4
4
  "description": "Install SiteDesk — automated back office for independent building contractors",
5
5
  "bin": {
6
6
  "create-sitedesk-code": "./dist/index.js"
@@ -38,7 +38,8 @@
38
38
  "assets": {
39
39
  "logo": "sitedesk-square.png",
40
40
  "icon": "sitedesk-monochrome.png",
41
- "favicon": "sitedesk-favicon-512.png"
41
+ "favicon": "sitedesk-favicon-512.png",
42
+ "ogCard": "sitedesk-og-image.png"
42
43
  },
43
44
 
44
45
  "npm": {
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: platform-architecture
3
3
  description: Use when grounding any documented-surface claim about what SiteDesk ships — plugins, skills, specialists, install/deploy flows, internals. This is the install catalogue, not evidence of what is enabled on the current account. For install state on this account, call `capabilities-here`; for documented surface, cite the `Source:` URL inline.
4
- content-hash: sha256:b692930990bf9196318b14c82f048bf430dc71d2a018690a3a14f817d4daa876
4
+ content-hash: sha256:f55a0f2ad57f4878b55fa1f2359de86050c9c88e61e16e9fe8d84bcb904b30b9
5
5
  brand: sitedesk-code
6
6
  product-name: SiteDesk
7
7
  ---
@@ -2334,6 +2334,8 @@ either is a regression.
2334
2334
 
2335
2335
  **`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp reader (`/whatsapp`) omits the prop and keeps the default `renderTimeline`, so its rendering is unchanged; both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.
2336
2336
 
2337
+ **`/chat` live activity line.** While a turn is in flight the transcript tail shows one ephemeral `ChatActivity` line (mounted only while `busy`, admin/operator only). It tracks real activity, not a timer: while a `Task` subagent runs it shows that subagent's headline (`agentType · description`, prefixed `N agents ·` when ≥2 are concurrent), sourced from `agent-<hex>.meta.json` via named `activity` SSE events the admin reader pushes from the session's `subagents/` dir; with no subagent active it shows a neutral word that advances only on a real turn arrival. The line carries a turn-elapsed clock and flips to a `stalled` state once nothing has been written for 5 minutes (`now − lastEmitAt`), so the operator can tell a wedge from progress without SSH. It is never added to the persisted timeline. Detail and the `[webchat-activity]` observability live in [`admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).
2338
+
2337
2339
  **maxy title for public sessions.** A `role=public` webchat spawn never produces a useful Claude Code `ai-title` (an anonymous one-line visitor turn), so every public row would otherwise read identically. The webchat route (`chat.ts`) composes a deterministic title — `Web · <senderId[:8]>[ · <personId>] · <UTC YYYY-MM-DD HH:mm>` (personId present only for gated visitors) — and threads it through the native webchat gateway's public spawn (`handleInbound` → `buildPublicWebchatSpawnRequest` → `managerSpawn`) to the manager `POST /public-spawn` body as `name`. `/spawn` validates it with `validateUserTitle` and, for `role=public` only, writes it into `UserTitleStore` so it occupies the operator-rename tier and wins over `ai-title`. Admin (`/rc-spawn`) and WhatsApp titling are unchanged. Observability: every public spawn logs `[spawn] role=public … title="…"` (or `title=missing`); the manager's row builder emits `[public-title] sessionId=… unexpected titleSource=<ai|null>` on the next list read for any public row that did not resolve from the user tier.
2338
2340
 
2339
2341
  **Public visitor surface.** The public-host root (`GET /`), the `/:slug` agent routes, and the admin-host `/public` / `/public-chat` previews all serve one shell, `public.html` → [`app/public-entry.tsx`](../../../ui/app/public-entry.tsx) → `PublicChat`. `PublicChat` reuses `useSession` purely as the magic-link gatekeeper — it drives `AccessGate`, resolves the agent slug from the path, and handles `?token=` verification; once the grant is satisfied it mounts `ChatSurface variant="public"`, whose transcript reads the visitor-scoped, **delivered-only** stream `GET /api/public-reader/stream` ([`server/routes/public-reader.ts`](../../../ui/server/routes/public-reader.ts)) — the visitor sees the agent's delivered prose, never the tool/tool-result/directive bytes the legacy `/api/chat` SSE render exposed. The human visitor's branding is resolved client-side by `useSession`. For the head only, the server injects per-agent link-preview meta — `<title>`, `og:title`/`og:description`/`og:image`, `theme-color`, and a per-agent favicon, resolved from the same branding cache `useSession` reads — into the served shell, so a link-preview crawler (which never runs the client bundle) gets a branded card per agent instead of one generic shell; an agent with no branding cache gets the clean brand-default shell with no empty meta tags. There is exactly one public client surface and it is 1:1 visitor↔agent: the earlier `?surface=next` A/B handle is retired, group messaging is retired (not a supported product surface), and a former `/g/<slug>` group URL now serves the same 1:1 shell — a stale bookmark gets the 1:1 chat, never a broken render.
@@ -69,6 +69,8 @@ either is a regression.
69
69
 
70
70
  **`/chat` Claude-desktop transcript presentation.** The admin webchat keeps the shared `Transcript` shell (stream, follow-tail) but injects a /chat-only item renderer via the component's optional `renderItems` prop: `renderChatTimeline` in `app/chat/transcript-render.tsx`. Presentation: operator turns render as a right-aligned grey bubble showing the message text and, beneath it, a subtle always-visible time-of-day stamp (no label); delivered agent replies render as plain prose with the same time-of-day stamp beneath (the reply-document filename line stays, as prose); the stamp is HH:MM from the turn's `ts` (locale-formatted, right-aligned inside the operator bubble, left-aligned under agent prose), and a turn whose `ts` is null/unparseable shows no stamp, never an empty line — so only delivered operator and agent-reply turns are stamped, while tool runs, the collapsed "Thinking" block, and the agent-error banner stay time-free; a maximal consecutive run of `tool-call`/`tool-result` turns renders as one collapsed grey one-liner ("Used N tools ›") whose expansion shows every call and result payload — a lone call still gets the one-liner, and prose or a directive row ends a run. The WhatsApp reader (`/whatsapp`) omits the prop and keeps the default `renderTimeline`, so its rendering is unchanged; both presentations are test-pinned (`app/whatsapp/__tests__/Transcript-*.test.tsx`, `app/chat/__tests__/transcript-render.test.tsx`). **Day-divider:** both renderers insert a centered `.day-divider` row between two consecutive timeline items on different local calendar days (label `Today`/`Yesterday`, else `Sat 14 Jun 2026`), so a thread spanning midnight is never an ambiguous run of HH:MM; the first dated item gets a leading divider, a null/unparseable `ts` marks no boundary, dividers are computed over the filtered `visibleItems`, and in `/chat` a day crossover flushes any open tool/think run first so a collapsed run never spans it. Per-bubble HH:MM stamps are unchanged. Shared helpers `dayKey`/`dayLabel`/`itemTs`/`DayDivider` live in `app/whatsapp/Transcript.tsx`.
71
71
 
72
+ **`/chat` live activity line.** While a turn is in flight the transcript tail shows one ephemeral `ChatActivity` line (mounted only while `busy`, admin/operator only). It tracks real activity, not a timer: while a `Task` subagent runs it shows that subagent's headline (`agentType · description`, prefixed `N agents ·` when ≥2 are concurrent), sourced from `agent-<hex>.meta.json` via named `activity` SSE events the admin reader pushes from the session's `subagents/` dir; with no subagent active it shows a neutral word that advances only on a real turn arrival. The line carries a turn-elapsed clock and flips to a `stalled` state once nothing has been written for 5 minutes (`now − lastEmitAt`), so the operator can tell a wedge from progress without SSH. It is never added to the persisted timeline. Detail and the `[webchat-activity]` observability live in [`admin-webchat-native-channel.md`](../../../.docs/admin-webchat-native-channel.md).
73
+
72
74
  **maxy title for public sessions.** A `role=public` webchat spawn never produces a useful Claude Code `ai-title` (an anonymous one-line visitor turn), so every public row would otherwise read identically. The webchat route (`chat.ts`) composes a deterministic title — `Web · <senderId[:8]>[ · <personId>] · <UTC YYYY-MM-DD HH:mm>` (personId present only for gated visitors) — and threads it through the native webchat gateway's public spawn (`handleInbound` → `buildPublicWebchatSpawnRequest` → `managerSpawn`) to the manager `POST /public-spawn` body as `name`. `/spawn` validates it with `validateUserTitle` and, for `role=public` only, writes it into `UserTitleStore` so it occupies the operator-rename tier and wins over `ai-title`. Admin (`/rc-spawn`) and WhatsApp titling are unchanged. Observability: every public spawn logs `[spawn] role=public … title="…"` (or `title=missing`); the manager's row builder emits `[public-title] sessionId=… unexpected titleSource=<ai|null>` on the next list read for any public row that did not resolve from the user tier.
73
75
 
74
76
  **Public visitor surface.** The public-host root (`GET /`), the `/:slug` agent routes, and the admin-host `/public` / `/public-chat` previews all serve one shell, `public.html` → [`app/public-entry.tsx`](../../../ui/app/public-entry.tsx) → `PublicChat`. `PublicChat` reuses `useSession` purely as the magic-link gatekeeper — it drives `AccessGate`, resolves the agent slug from the path, and handles `?token=` verification; once the grant is satisfied it mounts `ChatSurface variant="public"`, whose transcript reads the visitor-scoped, **delivered-only** stream `GET /api/public-reader/stream` ([`server/routes/public-reader.ts`](../../../ui/server/routes/public-reader.ts)) — the visitor sees the agent's delivered prose, never the tool/tool-result/directive bytes the legacy `/api/chat` SSE render exposed. The human visitor's branding is resolved client-side by `useSession`. For the head only, the server injects per-agent link-preview meta — `<title>`, `og:title`/`og:description`/`og:image`, `theme-color`, and a per-agent favicon, resolved from the same branding cache `useSession` reads — into the served shell, so a link-preview crawler (which never runs the client bundle) gets a branded card per agent instead of one generic shell; an agent with no branding cache gets the clean brand-default shell with no empty meta tags. There is exactly one public client surface and it is 1:1 visitor↔agent: the earlier `?surface=next` A/B handle is retired, group messaging is retired (not a supported product surface), and a former `/g/<slug>` group URL now serves the same 1:1 shell — a stale bookmark gets the 1:1 chat, never a broken render.
@@ -3113,6 +3113,22 @@ function renderLoginPage(opts) {
3113
3113
  const displayFont = opts?.displayFont ?? "'Newsreader', Georgia, serif";
3114
3114
  const bodyFont = opts?.bodyFont ?? "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
3115
3115
  const logoContainsName = opts?.logoContainsName ?? false;
3116
+ const tagline = opts?.tagline ?? "";
3117
+ const origin = opts?.origin ?? "";
3118
+ const ogCardPath = opts?.ogCardPath ?? "";
3119
+ const ogBlock = origin && ogCardPath ? [
3120
+ `<meta property="og:title" content="Sign in \u2014 ${escapeHtml(productName)}">`,
3121
+ `<meta property="og:description" content="${escapeHtml(tagline)}">`,
3122
+ `<meta property="og:type" content="website">`,
3123
+ `<meta property="og:url" content="${escapeHtml(origin)}">`,
3124
+ `<meta property="og:image" content="${escapeHtml(origin + ogCardPath)}">`,
3125
+ `<meta property="og:image:width" content="1200">`,
3126
+ `<meta property="og:image:height" content="630">`,
3127
+ `<meta name="twitter:card" content="summary_large_image">`,
3128
+ `<meta name="twitter:title" content="Sign in \u2014 ${escapeHtml(productName)}">`,
3129
+ `<meta name="twitter:description" content="${escapeHtml(tagline)}">`,
3130
+ `<meta name="twitter:image" content="${escapeHtml(origin + ogCardPath)}">`
3131
+ ].join("\n ") : "";
3116
3132
  const errorHtml = error ? `<p class="msg msg--error">${escapeHtml(error)}</p>` : "";
3117
3133
  const changeErrorHtml = changeError ? `<p class="msg msg--error">${escapeHtml(changeError)}</p>` : "";
3118
3134
  const successHtml = success ? `<p class="msg msg--success">${escapeHtml(success)}</p>` : "";
@@ -3148,6 +3164,7 @@ function renderLoginPage(opts) {
3148
3164
  <meta name="viewport" content="width=device-width, initial-scale=1">
3149
3165
  <title>Sign in \u2014 ${escapeHtml(productName)}</title>
3150
3166
  <link rel="icon" href="${escapeHtml(faviconPath)}">
3167
+ ${ogBlock}
3151
3168
  ${googleFontsLink}
3152
3169
  <style>
3153
3170
  * { margin: 0; padding: 0; box-sizing: border-box; }
@@ -16,7 +16,7 @@ import {
16
16
  sanitizeClientCorrId,
17
17
  vncLog,
18
18
  websockifyLog
19
- } from "./chunk-NE7G5GT7.js";
19
+ } from "./chunk-5BG6CHGH.js";
20
20
  import "./chunk-PFF6I7KP.js";
21
21
 
22
22
  // server/edge.ts
@@ -1 +1 @@
1
- import{o as e}from"./chunk-Pqm5yXtL.js";import{H as t,R as n,S as r,b as i,k as a,y as o}from"./OperatorConversations-RmqANYz8.js";import{i as s}from"./admin-types-CJrGd46U.js";import{g as c,u as l}from"./AdminShell-DjoP7YoA.js";import{t as u}from"./Checkbox-D58GsKoQ.js";var d=`admin-landing-redirected`,f=`/graph`;function p(e){return e.variant===`operator`?!1:e.appState===`chat`&&!e.alreadyRedirected}var m=e(t(),1);function h(e=`admin`){let[t,r]=(0,m.useState)(`loading`),[i,a]=(0,m.useState)(``),[o,l]=(0,m.useState)(``),[u,h]=(0,m.useState)(``),[g,_]=(0,m.useState)(!1),[v,y]=(0,m.useState)(!1),[b,x]=(0,m.useState)(!1),[S,C]=(0,m.useState)(!1),[w,T]=(0,m.useState)(!1),[E,D]=(0,m.useState)(null),[O,k]=(0,m.useState)(null),[A,j]=(0,m.useState)(void 0),[M,N]=(0,m.useState)(null),[P,F]=(0,m.useState)(void 0),[I,L]=(0,m.useState)(null),[ee,R]=(0,m.useState)(null),[z,B]=(0,m.useState)([]),[V,H]=(0,m.useState)(!1),[U,W]=(0,m.useState)(void 0),G=(0,m.useRef)(void 0),[K,q]=(0,m.useState)(!1);(0,m.useEffect)(()=>{typeof window>`u`||fetch(`/api/remote-auth/status`).then(e=>e.ok?e.json():null).then(e=>{e?.configured&&q(!0)}).catch(()=>{})},[]);let J=(0,m.useRef)(null),Y=(0,m.useRef)(null);(0,m.useEffect)(()=>{async function e(){let e=null;try{e=sessionStorage.getItem(`maxy-admin-session-key`)}catch{}if(!e)return!1;try{let t=await fetch(`/api/admin/session?session_key=${encodeURIComponent(e)}`);if(t.status===401){try{sessionStorage.removeItem(`maxy-admin-session-key`)}catch{}return!1}if(!t.ok)return!1;let n=await t.json();D(n.session_key),R(n.sessionId??null),j(n.businessName),N(n.role??null),F(n.userName===void 0?null:n.userName),L(n.avatar??null);let i=s(n.thinkingView);return G.current=i,W(i),r(`chat`),!0}catch(e){return console.error(`[admin] session restore failed:`,e),!1}}async function t(n=2){try{let i=await fetch(`/api/health`);if(!i.ok){if(n>0)return await new Promise(e=>setTimeout(e,1500)),t(n-1);console.error(`[admin] health check returned ${i.status} after retries`),r(`set-pin`);return}let a=await i.json();if(!a.pin_configured){r(`set-pin`);return}if(!a.claude_authenticated){r(`connect-claude`);return}if(await e())return;r(`enter-pin`)}catch(e){if(n>0)return await new Promise(e=>setTimeout(e,1500)),t(n-1);console.error(`[admin] health check failed:`,e),r(`set-pin`)}}t()},[]),(0,m.useEffect)(()=>{t===`chat`&&fetch(`/api/admin/claude-info`).then(e=>{if(e.ok)return e.json()}).then(e=>{e&&k(e)}).catch(()=>{})},[t]),(0,m.useEffect)(()=>{if(typeof window>`u`)return;let n=!1;try{n=sessionStorage.getItem(d)===`1`}catch{}if(p({appState:t,alreadyRedirected:n,variant:e})){try{sessionStorage.setItem(d,`1`)}catch{}console.info(`[admin-ui] landing-redirect target=${f}`),window.location.replace(f)}},[t,e]);let X=(0,m.useRef)(null);(0,m.useEffect)(()=>{if(t!==`chat`)return;let e=setInterval(async()=>{try{let e=await fetch(`/api/health`);if(e.ok){let t=await e.json();if(t.auth_status===`dead`||t.auth_status===`missing`){r(`connect-claude`);return}}}catch{}if(E)try{let e=await fetch(`/api/admin/session?session_key=${encodeURIComponent(E)}`);if(e.status!==401)return;let t=(await e.clone().json().catch(()=>null))?.code??`unknown-401`;if(t===`remote-auth-required`){n(`heartbeat`,`/api/admin/session`);return}console.warn(`[admin-auth] outcome=heartbeat-detected-expiry code=${t}`),X.current?.()}catch{}},300*1e3);return()=>clearInterval(e)},[t,E]),(0,m.useEffect)(()=>{t===`connect-claude`&&fetch(`/api/health`).then(e=>e.ok?e.json():null).then(e=>{e?.claude_authenticated&&r(`enter-pin`)}).catch(()=>{})},[t]);async function Z(e,t){y(!0);try{let n=await fetch(`/api/admin/session`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({pin:e,...t?{accountId:t}:{}})});if(!n.ok){h((await n.json().catch(()=>({}))).error||`Invalid PIN`);return}let i=await n.json();if(i.accounts&&!i.session_key){console.log(`[admin] account picker shown: userId=${i.userId} accountCount=${i.accounts.length}`),B(i.accounts),r(`account-picker`);return}D(i.session_key),R(i.sessionId??null),j(i.businessName),N(i.role??null),F(i.userName===void 0?null:i.userName),L(i.avatar??null);let o=s(i.thinkingView);if(G.current=o,W(o),t)try{sessionStorage.setItem(`maxy-account-id`,t)}catch{}try{sessionStorage.setItem(`maxy-admin-session-key`,i.session_key)}catch{}a(``),r(`chat`)}catch(e){console.error(`[admin] connection error:`,e),h(`Could not connect.`)}finally{y(!1),H(!1)}}let Q=(0,m.useCallback)(async e=>{if(e.preventDefault(),v)return;h(``);let t=o.trim();if(!t){h(`Please enter your name.`);return}if(i.length<4){h(`PIN must be at least 4 characters.`);return}let n=i;y(!0);try{let e=await fetch(`/api/onboarding/set-pin`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({pin:n,name:t})});if(!e.ok){let t=await e.json().catch(()=>({}));if(e.status===409){console.log(`[admin] PIN already configured — re-checking health`);try{let e=await fetch(`/api/health`);if(e.ok){let n=await e.json();n.pin_configured&&n.claude_authenticated?r(`enter-pin`):n.pin_configured?r(`connect-claude`):h(t.error||`Failed to set PIN.`)}else r(`enter-pin`)}catch{r(`enter-pin`)}return}h(t.error||`Failed to set PIN.`);return}let i=await fetch(`/api/health`);if((i.ok?await i.json():null)?.claude_authenticated){await Z(n);return}a(``),r(`connect-claude`)}catch(e){console.error(`[admin] connection error:`,e),h(`Could not connect.`)}finally{y(!1)}},[i,v,o]),te=(0,m.useCallback)(async e=>{e.preventDefault(),h(``),await Z(i)},[i]),ne=(0,m.useCallback)(async()=>{T(!0);try{if(!await c())return console.warn(`[admin-ui] claude-disconnect not verified — credentials may persist; staying put`),!1;D(null),N(null),F(void 0),L(null);try{sessionStorage.removeItem(`maxy-admin-session-key`),sessionStorage.removeItem(`maxy-account-id`),sessionStorage.removeItem(d)}catch{}return r(`connect-claude`),!0}finally{T(!1)}},[]),$=(0,m.useCallback)(()=>{D(null),N(null),F(void 0),L(null);try{sessionStorage.removeItem(`maxy-admin-session-key`),sessionStorage.removeItem(`maxy-account-id`),sessionStorage.removeItem(d)}catch{}a(``),h(``),r(`enter-pin`)},[]);return(0,m.useEffect)(()=>{X.current=$},[$]),{appState:t,setAppState:r,pin:i,setPin:a,operatorName:o,setOperatorName:l,pinError:u,setPinError:h,showPin:g,setShowPin:_,pinLoading:v,authPolling:b,setAuthPolling:x,authLoading:S,setAuthLoading:C,disconnecting:w,cacheKey:E,setCacheKey:D,claudeInfo:O,setClaudeInfo:k,businessName:A,role:M,userName:P,userAvatar:I,sessionId:ee,setSessionId:R,accounts:z,accountPickerLoading:V,expandAll:U,setExpandAll:W,expandAllDefaultRef:G,remoteAuthEnabled:K,pinInputRef:J,setPinFormRef:Y,handleSetPin:Q,handleLogin:te,handleAccountSelect:(0,m.useCallback)(async e=>{H(!0),h(``),await Z(i,e)},[i]),handleDisconnect:ne,handleLogout:$,handleChangePin:(0,m.useCallback)(async()=>{if(!i){h(`Enter your current PIN first.`);return}y(!0),h(``);try{let e=await fetch(`/api/onboarding/set-pin`,{method:`DELETE`,headers:{"Content-Type":`application/json`},body:JSON.stringify({currentPin:i})});if(!e.ok){h((await e.json().catch(()=>({error:`Incorrect PIN.`}))).error||`Incorrect PIN.`);return}a(``),h(``),r(`set-pin`)}catch(e){console.error(`[admin-auth] change pin failed:`,e),h(e instanceof Error?e.message:String(e))}finally{y(!1)}},[i])}}var g=r();function _({inputRef:e,value:t,onChange:n,onComplete:r,showPin:i,autoFocus:a}){let o=(0,m.useRef)([]);function s(e,r){r.key===`Backspace`?(r.preventDefault(),t[e]?n(t.slice(0,e)+t.slice(e+1)):e>0&&(n(t.slice(0,e-1)+t.slice(e)),o.current[e-1]?.focus())):r.key===`ArrowLeft`&&e>0?o.current[e-1]?.focus():r.key===`ArrowRight`&&e<5?o.current[e+1]?.focus():r.key===`Enter`&&(r.preventDefault(),r.currentTarget.form?.requestSubmit())}function c(e,i){let a=i.nativeEvent.data;if(!a||!/^\d$/.test(a))return;let s=t.split(``);for(s[e]=a;s.length<e;)s.push(``);let c=s.join(``).replace(/\D/g,``).slice(0,6);n(c),c.length===6?r?.(c):e<5&&o.current[e+1]?.focus()}function l(e){e.preventDefault();let t=e.clipboardData.getData(`text`).replace(/\D/g,``).slice(0,6);t&&(n(t),t.length===6?r?.(t):o.current[t.length]?.focus())}return(0,g.jsx)(`div`,{className:`pin-field`,children:Array.from({length:6}).map((n,r)=>(0,g.jsx)(`input`,{ref:t=>{o.current[r]=t,r===0&&e&&(e.current=t)},type:`text`,inputMode:`numeric`,className:`pin-box${t[r]?` pin-box-filled`:``}`,value:t[r]?i?t[r]:`•`:``,onKeyDown:e=>s(r,e),onInput:e=>c(r,e),onPaste:l,onFocus:e=>e.target.select(),autoFocus:a&&r===0,autoComplete:`off`,maxLength:1,"aria-label":`PIN digit ${r+1}`},r))})}function v(e){let{pin:t,setPin:n,showPin:r,setShowPin:a,pinLoading:s,pinError:c,pinInputRef:d,setPinFormRef:f,onSubmit:p,operatorName:m,setOperatorName:h}=e;return(0,g.jsx)(`div`,{className:`connect-page`,children:(0,g.jsxs)(`div`,{className:`connect-content`,children:[(0,g.jsx)(`img`,{src:i,alt:o.productName,className:`connect-logo connect-logo--maxy`}),!o.logoContainsName&&(0,g.jsxs)(`h1`,{className:`connect-title`,children:[`Welcome to `,o.productName]}),(0,g.jsxs)(`p`,{className:`connect-subtitle`,children:[`Tell `,o.productName,` who you are, then choose a PIN.`]}),(0,g.jsxs)(`form`,{ref:f,onSubmit:p,className:`connect-pin-form`,children:[(0,g.jsxs)(`div`,{className:`pin-input-row`,children:[(0,g.jsx)(`input`,{type:`text`,className:`connect-name-input`,placeholder:`Your full name`,value:m,onChange:e=>h(e.target.value),autoComplete:`name`,autoFocus:!0,required:!0,"aria-label":`Your full name`}),(0,g.jsx)(`div`,{style:{width:38,flexShrink:0},"aria-hidden":`true`})]}),(0,g.jsxs)(`div`,{className:`pin-input-row`,children:[(0,g.jsx)(_,{inputRef:d,value:t,onChange:n,onComplete:()=>{},showPin:r}),(0,g.jsx)(l,{variant:`send`,type:`submit`,disabled:!t||!m.trim(),loading:s,"aria-label":`Set PIN`,children:(0,g.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,g.jsx)(`line`,{x1:`5`,y1:`12`,x2:`19`,y2:`12`}),(0,g.jsx)(`polyline`,{points:`12 5 19 12 12 19`})]})})]}),(0,g.jsx)(u,{checked:r,onChange:()=>a(e=>!e),label:`Show PIN`})]}),c&&(0,g.jsx)(`p`,{className:`admin-pin-error`,children:c})]})})}function y(e){let{pin:t,setPin:n,showPin:r,setShowPin:a,pinLoading:s,pinError:c,pinInputRef:d,onSubmit:f,onChangePin:p,remoteAuthEnabled:m,onSignOutRemote:h}=e;return(0,g.jsxs)(`div`,{className:`connect-page`,children:[m&&h&&(0,g.jsx)(`button`,{type:`button`,className:`connect-signout`,onClick:h,children:`Sign out`}),(0,g.jsxs)(`div`,{className:`connect-content`,children:[(0,g.jsx)(`img`,{src:i,alt:o.productName,className:`connect-logo connect-logo--maxy`}),!o.logoContainsName&&(0,g.jsx)(`h1`,{className:`connect-title`,children:o.productName}),(0,g.jsxs)(`form`,{onSubmit:f,className:`connect-pin-form`,children:[(0,g.jsxs)(`div`,{className:`pin-input-row`,children:[(0,g.jsx)(_,{inputRef:d,value:t,onChange:n,onComplete:()=>{},showPin:r,autoFocus:!0}),(0,g.jsx)(l,{variant:`send`,type:`submit`,disabled:!t,loading:s,children:(0,g.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,g.jsx)(`line`,{x1:`5`,y1:`12`,x2:`19`,y2:`12`}),(0,g.jsx)(`polyline`,{points:`12 5 19 12 12 19`})]})})]}),(0,g.jsxs)(`div`,{className:`pin-options`,children:[(0,g.jsx)(u,{checked:r,onChange:()=>a(e=>!e),label:`Show PIN`}),(0,g.jsx)(l,{type:`button`,variant:`ghost`,onClick:p,children:`Change PIN`})]})]}),c&&(0,g.jsx)(`p`,{className:`admin-pin-error`,children:c})]})]})}function b(e){let{accounts:t,loading:n,error:r,onSelect:s}=e;return(0,g.jsx)(`div`,{className:`connect-page`,children:(0,g.jsxs)(`div`,{className:`connect-content`,children:[(0,g.jsx)(`img`,{src:i,alt:o.productName,className:`connect-logo connect-logo--maxy`}),!o.logoContainsName&&(0,g.jsx)(`h1`,{className:`connect-title`,children:o.productName}),(0,g.jsx)(`p`,{className:`connect-subtitle`,children:`Select an account`}),(0,g.jsx)(`div`,{className:`account-picker-list`,children:t.map(e=>(0,g.jsxs)(`button`,{className:`account-picker-card`,onClick:()=>s(e.accountId),disabled:n,type:`button`,children:[(0,g.jsx)(`span`,{className:`account-picker-name`,children:e.businessName||e.accountId}),(0,g.jsx)(`span`,{className:`account-picker-role`,children:e.role}),n&&(0,g.jsx)(a,{className:`account-picker-spinner`,size:16})]},e.accountId))}),r&&(0,g.jsx)(`p`,{className:`admin-pin-error`,children:r})]})})}function x(e){let{authPolling:t,setAuthPolling:n,authLoading:r,setAuthLoading:a,pinError:s,setPinError:c,setAppState:u}=e,[d,f]=(0,m.useState)(!1),[p,h]=(0,m.useState)(!1);async function _(){h(!0),c(``);try{let e=await(await fetch(`/api/onboarding/claude-auth`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({action:`launch-browser`})})).json();e.launched?f(!0):e.error&&c(e.error)}catch(e){console.error(`[admin] browser launch error:`,e),c(`Could not launch browser.`)}h(!1)}async function v(){a(!0),c(``);try{let e=await(await fetch(`/api/onboarding/claude-auth`,{method:`POST`})).json();if(e.started){n(!0),f(!0),a(!1);for(let e=0;e<120;e++)if(await new Promise(e=>setTimeout(e,2e3)),(await(await fetch(`/api/onboarding/claude-auth`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({action:`wait`})})).json()).authenticated){await fetch(`/api/onboarding/claude-auth`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({action:`stop`})}),u(`enter-pin`);return}c(`Timed out waiting for sign-in. Try again.`),n(!1)}else e.error&&c(e.error)}catch(e){console.error(`[admin] auth flow error:`,e),c(`Could not start auth flow.`)}a(!1)}async function y(){await fetch(`/api/onboarding/claude-auth`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({action:`stop`})}),n(!1),c(``)}return t||d?(0,g.jsxs)(`div`,{style:{display:`flex`,flexDirection:`column`,height:`100dvh`,overflow:`auto`},children:[(0,g.jsxs)(`header`,{className:`chat-header`,style:{paddingBottom:`12px`,flexShrink:0,position:`relative`,maxWidth:`680px`,width:`100%`,margin:`0 auto`,padding:`24px 20px 12px`},children:[t?(0,g.jsx)(`button`,{onClick:y,style:{position:`absolute`,top:`12px`,right:`12px`,background:`none`,border:`none`,color:`#999`,fontSize:`13px`,cursor:`pointer`,padding:`4px 8px`},"aria-label":`Cancel`,children:`✕`}):(0,g.jsx)(`button`,{onClick:()=>f(!1),style:{position:`absolute`,top:`12px`,right:`12px`,background:`none`,border:`none`,color:`#999`,fontSize:`13px`,cursor:`pointer`,padding:`4px 8px`},"aria-label":`Close browser`,children:`✕`}),(0,g.jsx)(`img`,{src:`/brand/claude.png`,alt:`Claude`,className:`chat-logo`}),(0,g.jsx)(`h1`,{className:`chat-tagline`,children:`Connect Claude`}),(0,g.jsx)(`p`,{className:`chat-intro`,children:t?`Sign in and authorize in the browser below.`:`Open your email or prepare your accounts, then sign in.`}),!t&&(0,g.jsx)(`div`,{style:{marginTop:`12px`},children:(0,g.jsx)(l,{variant:`primary`,onClick:v,disabled:r,children:r?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`span`,{className:`spin`,style:{display:`inline-block`},children:`✱`}),` Connecting…`]}):`Sign in to Claude`})})]}),(0,g.jsx)(`div`,{style:{flex:1,display:`flex`,flexDirection:`column`,minHeight:0,gap:`10px`,padding:`0 0 16px`},children:(0,g.jsx)(`iframe`,{src:`/vnc-viewer.html`,style:{flex:1,width:`100%`,minHeight:0,border:`none`,background:`#111`,display:`block`},title:`Claude Sign-in`})}),s&&(0,g.jsx)(`p`,{className:`admin-pin-error`,style:{textAlign:`center`,padding:`0 20px 16px`},children:s})]}):(0,g.jsx)(`div`,{className:`connect-page`,children:(0,g.jsxs)(`div`,{className:`connect-content`,children:[(0,g.jsxs)(`div`,{className:`connect-logos`,children:[(0,g.jsx)(`div`,{className:`connect-logo-wrap`,children:(0,g.jsx)(`img`,{src:`/brand/claude.png`,alt:`Claude`,className:`connect-logo`})}),(0,g.jsx)(`svg`,{className:`connect-arrow`,viewBox:`0 0 48 24`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`,children:(0,g.jsx)(`path`,{d:`M0 12h44m0 0l-8-8m8 8l-8 8`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`})}),(0,g.jsxs)(`div`,{className:`connect-logo-wrap`,children:[(0,g.jsx)(`img`,{src:i,alt:o.productName,className:`connect-logo connect-logo--maxy`}),!o.logoContainsName&&(0,g.jsx)(`span`,{className:`connect-logo-label`,children:o.productName})]})]}),(0,g.jsxs)(`h1`,{className:`connect-title`,children:[`Connect Claude to power `,o.productName]}),(0,g.jsx)(`p`,{className:`connect-subtitle`,children:`Sign in with your Anthropic account to get started.`}),(0,g.jsx)(l,{variant:`primary`,onClick:v,disabled:r,children:r?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`span`,{className:`spin`,style:{display:`inline-block`},children:`✱`}),` Connecting…`]}):`Sign in to Claude`}),(0,g.jsx)(`p`,{style:{marginTop:`6px`,fontSize:`11px`,color:`#999`,maxWidth:`300px`,textAlign:`center`,lineHeight:`1.4`},children:`First time? You may need to sign into your email and Anthropic account in the browser before connecting.`}),(0,g.jsx)(`button`,{onClick:_,disabled:p,style:{marginTop:`12px`,background:`none`,border:`none`,color:`var(--color-primary, #666)`,fontSize:`13px`,cursor:`pointer`,textDecoration:`underline`,textUnderlineOffset:`3px`},children:p?`Launching…`:`Open browser first`}),s&&(0,g.jsx)(`p`,{className:`admin-pin-error`,children:s})]})})}function S({auth:e}){return e.appState===`loading`?(0,g.jsx)(`div`,{className:`connect-page`}):e.appState===`set-pin`?(0,g.jsx)(v,{pin:e.pin,setPin:e.setPin,showPin:e.showPin,setShowPin:e.setShowPin,pinLoading:e.pinLoading,pinError:e.pinError,pinInputRef:e.pinInputRef,setPinFormRef:e.setPinFormRef,onSubmit:e.handleSetPin,operatorName:e.operatorName,setOperatorName:e.setOperatorName}):e.appState===`connect-claude`?(0,g.jsx)(x,{authPolling:e.authPolling,setAuthPolling:e.setAuthPolling,authLoading:e.authLoading,setAuthLoading:e.setAuthLoading,pinError:e.pinError,setPinError:e.setPinError,setAppState:e.setAppState}):e.appState===`enter-pin`?(0,g.jsx)(y,{pin:e.pin,setPin:e.setPin,showPin:e.showPin,setShowPin:e.setShowPin,pinLoading:e.pinLoading,pinError:e.pinError,pinInputRef:e.pinInputRef,onSubmit:e.handleLogin,onChangePin:e.handleChangePin,remoteAuthEnabled:e.remoteAuthEnabled,onSignOutRemote:()=>{console.info(`[admin-ui] remote-auth sign-out → /__remote-auth/logout`),window.location.href=`/__remote-auth/logout`}}):e.appState===`account-picker`?(0,g.jsx)(b,{accounts:e.accounts,loading:e.accountPickerLoading,error:e.pinError,onSelect:e.handleAccountSelect}):null}export{h as n,S as t};
1
+ import{o as e}from"./chunk-Pqm5yXtL.js";import{H as t,R as n,S as r,b as i,k as a,y as o}from"./OperatorConversations-DpjPPIOp.js";import{i as s}from"./admin-types-CJrGd46U.js";import{g as c,u as l}from"./AdminShell-CHZMDX2u.js";import{t as u}from"./Checkbox-aePjWzRH.js";var d=`admin-landing-redirected`,f=`/graph`;function p(e){return e.variant===`operator`?!1:e.appState===`chat`&&!e.alreadyRedirected}var m=e(t(),1);function h(e=`admin`){let[t,r]=(0,m.useState)(`loading`),[i,a]=(0,m.useState)(``),[o,l]=(0,m.useState)(``),[u,h]=(0,m.useState)(``),[g,_]=(0,m.useState)(!1),[v,y]=(0,m.useState)(!1),[b,x]=(0,m.useState)(!1),[S,C]=(0,m.useState)(!1),[w,T]=(0,m.useState)(!1),[E,D]=(0,m.useState)(null),[O,k]=(0,m.useState)(null),[A,j]=(0,m.useState)(void 0),[M,N]=(0,m.useState)(null),[P,F]=(0,m.useState)(void 0),[I,L]=(0,m.useState)(null),[ee,R]=(0,m.useState)(null),[z,B]=(0,m.useState)([]),[V,H]=(0,m.useState)(!1),[U,W]=(0,m.useState)(void 0),G=(0,m.useRef)(void 0),[K,q]=(0,m.useState)(!1);(0,m.useEffect)(()=>{typeof window>`u`||fetch(`/api/remote-auth/status`).then(e=>e.ok?e.json():null).then(e=>{e?.configured&&q(!0)}).catch(()=>{})},[]);let J=(0,m.useRef)(null),Y=(0,m.useRef)(null);(0,m.useEffect)(()=>{async function e(){let e=null;try{e=sessionStorage.getItem(`maxy-admin-session-key`)}catch{}if(!e)return!1;try{let t=await fetch(`/api/admin/session?session_key=${encodeURIComponent(e)}`);if(t.status===401){try{sessionStorage.removeItem(`maxy-admin-session-key`)}catch{}return!1}if(!t.ok)return!1;let n=await t.json();D(n.session_key),R(n.sessionId??null),j(n.businessName),N(n.role??null),F(n.userName===void 0?null:n.userName),L(n.avatar??null);let i=s(n.thinkingView);return G.current=i,W(i),r(`chat`),!0}catch(e){return console.error(`[admin] session restore failed:`,e),!1}}async function t(n=2){try{let i=await fetch(`/api/health`);if(!i.ok){if(n>0)return await new Promise(e=>setTimeout(e,1500)),t(n-1);console.error(`[admin] health check returned ${i.status} after retries`),r(`set-pin`);return}let a=await i.json();if(!a.pin_configured){r(`set-pin`);return}if(!a.claude_authenticated){r(`connect-claude`);return}if(await e())return;r(`enter-pin`)}catch(e){if(n>0)return await new Promise(e=>setTimeout(e,1500)),t(n-1);console.error(`[admin] health check failed:`,e),r(`set-pin`)}}t()},[]),(0,m.useEffect)(()=>{t===`chat`&&fetch(`/api/admin/claude-info`).then(e=>{if(e.ok)return e.json()}).then(e=>{e&&k(e)}).catch(()=>{})},[t]),(0,m.useEffect)(()=>{if(typeof window>`u`)return;let n=!1;try{n=sessionStorage.getItem(d)===`1`}catch{}if(p({appState:t,alreadyRedirected:n,variant:e})){try{sessionStorage.setItem(d,`1`)}catch{}console.info(`[admin-ui] landing-redirect target=${f}`),window.location.replace(f)}},[t,e]);let X=(0,m.useRef)(null);(0,m.useEffect)(()=>{if(t!==`chat`)return;let e=setInterval(async()=>{try{let e=await fetch(`/api/health`);if(e.ok){let t=await e.json();if(t.auth_status===`dead`||t.auth_status===`missing`){r(`connect-claude`);return}}}catch{}if(E)try{let e=await fetch(`/api/admin/session?session_key=${encodeURIComponent(E)}`);if(e.status!==401)return;let t=(await e.clone().json().catch(()=>null))?.code??`unknown-401`;if(t===`remote-auth-required`){n(`heartbeat`,`/api/admin/session`);return}console.warn(`[admin-auth] outcome=heartbeat-detected-expiry code=${t}`),X.current?.()}catch{}},300*1e3);return()=>clearInterval(e)},[t,E]),(0,m.useEffect)(()=>{t===`connect-claude`&&fetch(`/api/health`).then(e=>e.ok?e.json():null).then(e=>{e?.claude_authenticated&&r(`enter-pin`)}).catch(()=>{})},[t]);async function Z(e,t){y(!0);try{let n=await fetch(`/api/admin/session`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({pin:e,...t?{accountId:t}:{}})});if(!n.ok){h((await n.json().catch(()=>({}))).error||`Invalid PIN`);return}let i=await n.json();if(i.accounts&&!i.session_key){console.log(`[admin] account picker shown: userId=${i.userId} accountCount=${i.accounts.length}`),B(i.accounts),r(`account-picker`);return}D(i.session_key),R(i.sessionId??null),j(i.businessName),N(i.role??null),F(i.userName===void 0?null:i.userName),L(i.avatar??null);let o=s(i.thinkingView);if(G.current=o,W(o),t)try{sessionStorage.setItem(`maxy-account-id`,t)}catch{}try{sessionStorage.setItem(`maxy-admin-session-key`,i.session_key)}catch{}a(``),r(`chat`)}catch(e){console.error(`[admin] connection error:`,e),h(`Could not connect.`)}finally{y(!1),H(!1)}}let Q=(0,m.useCallback)(async e=>{if(e.preventDefault(),v)return;h(``);let t=o.trim();if(!t){h(`Please enter your name.`);return}if(i.length<4){h(`PIN must be at least 4 characters.`);return}let n=i;y(!0);try{let e=await fetch(`/api/onboarding/set-pin`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({pin:n,name:t})});if(!e.ok){let t=await e.json().catch(()=>({}));if(e.status===409){console.log(`[admin] PIN already configured — re-checking health`);try{let e=await fetch(`/api/health`);if(e.ok){let n=await e.json();n.pin_configured&&n.claude_authenticated?r(`enter-pin`):n.pin_configured?r(`connect-claude`):h(t.error||`Failed to set PIN.`)}else r(`enter-pin`)}catch{r(`enter-pin`)}return}h(t.error||`Failed to set PIN.`);return}let i=await fetch(`/api/health`);if((i.ok?await i.json():null)?.claude_authenticated){await Z(n);return}a(``),r(`connect-claude`)}catch(e){console.error(`[admin] connection error:`,e),h(`Could not connect.`)}finally{y(!1)}},[i,v,o]),te=(0,m.useCallback)(async e=>{e.preventDefault(),h(``),await Z(i)},[i]),ne=(0,m.useCallback)(async()=>{T(!0);try{if(!await c())return console.warn(`[admin-ui] claude-disconnect not verified — credentials may persist; staying put`),!1;D(null),N(null),F(void 0),L(null);try{sessionStorage.removeItem(`maxy-admin-session-key`),sessionStorage.removeItem(`maxy-account-id`),sessionStorage.removeItem(d)}catch{}return r(`connect-claude`),!0}finally{T(!1)}},[]),$=(0,m.useCallback)(()=>{D(null),N(null),F(void 0),L(null);try{sessionStorage.removeItem(`maxy-admin-session-key`),sessionStorage.removeItem(`maxy-account-id`),sessionStorage.removeItem(d)}catch{}a(``),h(``),r(`enter-pin`)},[]);return(0,m.useEffect)(()=>{X.current=$},[$]),{appState:t,setAppState:r,pin:i,setPin:a,operatorName:o,setOperatorName:l,pinError:u,setPinError:h,showPin:g,setShowPin:_,pinLoading:v,authPolling:b,setAuthPolling:x,authLoading:S,setAuthLoading:C,disconnecting:w,cacheKey:E,setCacheKey:D,claudeInfo:O,setClaudeInfo:k,businessName:A,role:M,userName:P,userAvatar:I,sessionId:ee,setSessionId:R,accounts:z,accountPickerLoading:V,expandAll:U,setExpandAll:W,expandAllDefaultRef:G,remoteAuthEnabled:K,pinInputRef:J,setPinFormRef:Y,handleSetPin:Q,handleLogin:te,handleAccountSelect:(0,m.useCallback)(async e=>{H(!0),h(``),await Z(i,e)},[i]),handleDisconnect:ne,handleLogout:$,handleChangePin:(0,m.useCallback)(async()=>{if(!i){h(`Enter your current PIN first.`);return}y(!0),h(``);try{let e=await fetch(`/api/onboarding/set-pin`,{method:`DELETE`,headers:{"Content-Type":`application/json`},body:JSON.stringify({currentPin:i})});if(!e.ok){h((await e.json().catch(()=>({error:`Incorrect PIN.`}))).error||`Incorrect PIN.`);return}a(``),h(``),r(`set-pin`)}catch(e){console.error(`[admin-auth] change pin failed:`,e),h(e instanceof Error?e.message:String(e))}finally{y(!1)}},[i])}}var g=r();function _({inputRef:e,value:t,onChange:n,onComplete:r,showPin:i,autoFocus:a}){let o=(0,m.useRef)([]);function s(e,r){r.key===`Backspace`?(r.preventDefault(),t[e]?n(t.slice(0,e)+t.slice(e+1)):e>0&&(n(t.slice(0,e-1)+t.slice(e)),o.current[e-1]?.focus())):r.key===`ArrowLeft`&&e>0?o.current[e-1]?.focus():r.key===`ArrowRight`&&e<5?o.current[e+1]?.focus():r.key===`Enter`&&(r.preventDefault(),r.currentTarget.form?.requestSubmit())}function c(e,i){let a=i.nativeEvent.data;if(!a||!/^\d$/.test(a))return;let s=t.split(``);for(s[e]=a;s.length<e;)s.push(``);let c=s.join(``).replace(/\D/g,``).slice(0,6);n(c),c.length===6?r?.(c):e<5&&o.current[e+1]?.focus()}function l(e){e.preventDefault();let t=e.clipboardData.getData(`text`).replace(/\D/g,``).slice(0,6);t&&(n(t),t.length===6?r?.(t):o.current[t.length]?.focus())}return(0,g.jsx)(`div`,{className:`pin-field`,children:Array.from({length:6}).map((n,r)=>(0,g.jsx)(`input`,{ref:t=>{o.current[r]=t,r===0&&e&&(e.current=t)},type:`text`,inputMode:`numeric`,className:`pin-box${t[r]?` pin-box-filled`:``}`,value:t[r]?i?t[r]:`•`:``,onKeyDown:e=>s(r,e),onInput:e=>c(r,e),onPaste:l,onFocus:e=>e.target.select(),autoFocus:a&&r===0,autoComplete:`off`,maxLength:1,"aria-label":`PIN digit ${r+1}`},r))})}function v(e){let{pin:t,setPin:n,showPin:r,setShowPin:a,pinLoading:s,pinError:c,pinInputRef:d,setPinFormRef:f,onSubmit:p,operatorName:m,setOperatorName:h}=e;return(0,g.jsx)(`div`,{className:`connect-page`,children:(0,g.jsxs)(`div`,{className:`connect-content`,children:[(0,g.jsx)(`img`,{src:i,alt:o.productName,className:`connect-logo connect-logo--maxy`}),!o.logoContainsName&&(0,g.jsxs)(`h1`,{className:`connect-title`,children:[`Welcome to `,o.productName]}),(0,g.jsxs)(`p`,{className:`connect-subtitle`,children:[`Tell `,o.productName,` who you are, then choose a PIN.`]}),(0,g.jsxs)(`form`,{ref:f,onSubmit:p,className:`connect-pin-form`,children:[(0,g.jsxs)(`div`,{className:`pin-input-row`,children:[(0,g.jsx)(`input`,{type:`text`,className:`connect-name-input`,placeholder:`Your full name`,value:m,onChange:e=>h(e.target.value),autoComplete:`name`,autoFocus:!0,required:!0,"aria-label":`Your full name`}),(0,g.jsx)(`div`,{style:{width:38,flexShrink:0},"aria-hidden":`true`})]}),(0,g.jsxs)(`div`,{className:`pin-input-row`,children:[(0,g.jsx)(_,{inputRef:d,value:t,onChange:n,onComplete:()=>{},showPin:r}),(0,g.jsx)(l,{variant:`send`,type:`submit`,disabled:!t||!m.trim(),loading:s,"aria-label":`Set PIN`,children:(0,g.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,g.jsx)(`line`,{x1:`5`,y1:`12`,x2:`19`,y2:`12`}),(0,g.jsx)(`polyline`,{points:`12 5 19 12 12 19`})]})})]}),(0,g.jsx)(u,{checked:r,onChange:()=>a(e=>!e),label:`Show PIN`})]}),c&&(0,g.jsx)(`p`,{className:`admin-pin-error`,children:c})]})})}function y(e){let{pin:t,setPin:n,showPin:r,setShowPin:a,pinLoading:s,pinError:c,pinInputRef:d,onSubmit:f,onChangePin:p,remoteAuthEnabled:m,onSignOutRemote:h}=e;return(0,g.jsxs)(`div`,{className:`connect-page`,children:[m&&h&&(0,g.jsx)(`button`,{type:`button`,className:`connect-signout`,onClick:h,children:`Sign out`}),(0,g.jsxs)(`div`,{className:`connect-content`,children:[(0,g.jsx)(`img`,{src:i,alt:o.productName,className:`connect-logo connect-logo--maxy`}),!o.logoContainsName&&(0,g.jsx)(`h1`,{className:`connect-title`,children:o.productName}),(0,g.jsxs)(`form`,{onSubmit:f,className:`connect-pin-form`,children:[(0,g.jsxs)(`div`,{className:`pin-input-row`,children:[(0,g.jsx)(_,{inputRef:d,value:t,onChange:n,onComplete:()=>{},showPin:r,autoFocus:!0}),(0,g.jsx)(l,{variant:`send`,type:`submit`,disabled:!t,loading:s,children:(0,g.jsxs)(`svg`,{viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`,children:[(0,g.jsx)(`line`,{x1:`5`,y1:`12`,x2:`19`,y2:`12`}),(0,g.jsx)(`polyline`,{points:`12 5 19 12 12 19`})]})})]}),(0,g.jsxs)(`div`,{className:`pin-options`,children:[(0,g.jsx)(u,{checked:r,onChange:()=>a(e=>!e),label:`Show PIN`}),(0,g.jsx)(l,{type:`button`,variant:`ghost`,onClick:p,children:`Change PIN`})]})]}),c&&(0,g.jsx)(`p`,{className:`admin-pin-error`,children:c})]})]})}function b(e){let{accounts:t,loading:n,error:r,onSelect:s}=e;return(0,g.jsx)(`div`,{className:`connect-page`,children:(0,g.jsxs)(`div`,{className:`connect-content`,children:[(0,g.jsx)(`img`,{src:i,alt:o.productName,className:`connect-logo connect-logo--maxy`}),!o.logoContainsName&&(0,g.jsx)(`h1`,{className:`connect-title`,children:o.productName}),(0,g.jsx)(`p`,{className:`connect-subtitle`,children:`Select an account`}),(0,g.jsx)(`div`,{className:`account-picker-list`,children:t.map(e=>(0,g.jsxs)(`button`,{className:`account-picker-card`,onClick:()=>s(e.accountId),disabled:n,type:`button`,children:[(0,g.jsx)(`span`,{className:`account-picker-name`,children:e.businessName||e.accountId}),(0,g.jsx)(`span`,{className:`account-picker-role`,children:e.role}),n&&(0,g.jsx)(a,{className:`account-picker-spinner`,size:16})]},e.accountId))}),r&&(0,g.jsx)(`p`,{className:`admin-pin-error`,children:r})]})})}function x(e){let{authPolling:t,setAuthPolling:n,authLoading:r,setAuthLoading:a,pinError:s,setPinError:c,setAppState:u}=e,[d,f]=(0,m.useState)(!1),[p,h]=(0,m.useState)(!1);async function _(){h(!0),c(``);try{let e=await(await fetch(`/api/onboarding/claude-auth`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({action:`launch-browser`})})).json();e.launched?f(!0):e.error&&c(e.error)}catch(e){console.error(`[admin] browser launch error:`,e),c(`Could not launch browser.`)}h(!1)}async function v(){a(!0),c(``);try{let e=await(await fetch(`/api/onboarding/claude-auth`,{method:`POST`})).json();if(e.started){n(!0),f(!0),a(!1);for(let e=0;e<120;e++)if(await new Promise(e=>setTimeout(e,2e3)),(await(await fetch(`/api/onboarding/claude-auth`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({action:`wait`})})).json()).authenticated){await fetch(`/api/onboarding/claude-auth`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({action:`stop`})}),u(`enter-pin`);return}c(`Timed out waiting for sign-in. Try again.`),n(!1)}else e.error&&c(e.error)}catch(e){console.error(`[admin] auth flow error:`,e),c(`Could not start auth flow.`)}a(!1)}async function y(){await fetch(`/api/onboarding/claude-auth`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({action:`stop`})}),n(!1),c(``)}return t||d?(0,g.jsxs)(`div`,{style:{display:`flex`,flexDirection:`column`,height:`100dvh`,overflow:`auto`},children:[(0,g.jsxs)(`header`,{className:`chat-header`,style:{paddingBottom:`12px`,flexShrink:0,position:`relative`,maxWidth:`680px`,width:`100%`,margin:`0 auto`,padding:`24px 20px 12px`},children:[t?(0,g.jsx)(`button`,{onClick:y,style:{position:`absolute`,top:`12px`,right:`12px`,background:`none`,border:`none`,color:`#999`,fontSize:`13px`,cursor:`pointer`,padding:`4px 8px`},"aria-label":`Cancel`,children:`✕`}):(0,g.jsx)(`button`,{onClick:()=>f(!1),style:{position:`absolute`,top:`12px`,right:`12px`,background:`none`,border:`none`,color:`#999`,fontSize:`13px`,cursor:`pointer`,padding:`4px 8px`},"aria-label":`Close browser`,children:`✕`}),(0,g.jsx)(`img`,{src:`/brand/claude.png`,alt:`Claude`,className:`chat-logo`}),(0,g.jsx)(`h1`,{className:`chat-tagline`,children:`Connect Claude`}),(0,g.jsx)(`p`,{className:`chat-intro`,children:t?`Sign in and authorize in the browser below.`:`Open your email or prepare your accounts, then sign in.`}),!t&&(0,g.jsx)(`div`,{style:{marginTop:`12px`},children:(0,g.jsx)(l,{variant:`primary`,onClick:v,disabled:r,children:r?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`span`,{className:`spin`,style:{display:`inline-block`},children:`✱`}),` Connecting…`]}):`Sign in to Claude`})})]}),(0,g.jsx)(`div`,{style:{flex:1,display:`flex`,flexDirection:`column`,minHeight:0,gap:`10px`,padding:`0 0 16px`},children:(0,g.jsx)(`iframe`,{src:`/vnc-viewer.html`,style:{flex:1,width:`100%`,minHeight:0,border:`none`,background:`#111`,display:`block`},title:`Claude Sign-in`})}),s&&(0,g.jsx)(`p`,{className:`admin-pin-error`,style:{textAlign:`center`,padding:`0 20px 16px`},children:s})]}):(0,g.jsx)(`div`,{className:`connect-page`,children:(0,g.jsxs)(`div`,{className:`connect-content`,children:[(0,g.jsxs)(`div`,{className:`connect-logos`,children:[(0,g.jsx)(`div`,{className:`connect-logo-wrap`,children:(0,g.jsx)(`img`,{src:`/brand/claude.png`,alt:`Claude`,className:`connect-logo`})}),(0,g.jsx)(`svg`,{className:`connect-arrow`,viewBox:`0 0 48 24`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`,children:(0,g.jsx)(`path`,{d:`M0 12h44m0 0l-8-8m8 8l-8 8`,stroke:`currentColor`,strokeWidth:`2`,strokeLinecap:`round`,strokeLinejoin:`round`})}),(0,g.jsxs)(`div`,{className:`connect-logo-wrap`,children:[(0,g.jsx)(`img`,{src:i,alt:o.productName,className:`connect-logo connect-logo--maxy`}),!o.logoContainsName&&(0,g.jsx)(`span`,{className:`connect-logo-label`,children:o.productName})]})]}),(0,g.jsxs)(`h1`,{className:`connect-title`,children:[`Connect Claude to power `,o.productName]}),(0,g.jsx)(`p`,{className:`connect-subtitle`,children:`Sign in with your Anthropic account to get started.`}),(0,g.jsx)(l,{variant:`primary`,onClick:v,disabled:r,children:r?(0,g.jsxs)(g.Fragment,{children:[(0,g.jsx)(`span`,{className:`spin`,style:{display:`inline-block`},children:`✱`}),` Connecting…`]}):`Sign in to Claude`}),(0,g.jsx)(`p`,{style:{marginTop:`6px`,fontSize:`11px`,color:`#999`,maxWidth:`300px`,textAlign:`center`,lineHeight:`1.4`},children:`First time? You may need to sign into your email and Anthropic account in the browser before connecting.`}),(0,g.jsx)(`button`,{onClick:_,disabled:p,style:{marginTop:`12px`,background:`none`,border:`none`,color:`var(--color-primary, #666)`,fontSize:`13px`,cursor:`pointer`,textDecoration:`underline`,textUnderlineOffset:`3px`},children:p?`Launching…`:`Open browser first`}),s&&(0,g.jsx)(`p`,{className:`admin-pin-error`,children:s})]})})}function S({auth:e}){return e.appState===`loading`?(0,g.jsx)(`div`,{className:`connect-page`}):e.appState===`set-pin`?(0,g.jsx)(v,{pin:e.pin,setPin:e.setPin,showPin:e.showPin,setShowPin:e.setShowPin,pinLoading:e.pinLoading,pinError:e.pinError,pinInputRef:e.pinInputRef,setPinFormRef:e.setPinFormRef,onSubmit:e.handleSetPin,operatorName:e.operatorName,setOperatorName:e.setOperatorName}):e.appState===`connect-claude`?(0,g.jsx)(x,{authPolling:e.authPolling,setAuthPolling:e.setAuthPolling,authLoading:e.authLoading,setAuthLoading:e.setAuthLoading,pinError:e.pinError,setPinError:e.setPinError,setAppState:e.setAppState}):e.appState===`enter-pin`?(0,g.jsx)(y,{pin:e.pin,setPin:e.setPin,showPin:e.showPin,setShowPin:e.setShowPin,pinLoading:e.pinLoading,pinError:e.pinError,pinInputRef:e.pinInputRef,onSubmit:e.handleLogin,onChangePin:e.handleChangePin,remoteAuthEnabled:e.remoteAuthEnabled,onSignOutRemote:()=>{console.info(`[admin-ui] remote-auth sign-out → /__remote-auth/logout`),window.location.href=`/__remote-auth/logout`}}):e.appState===`account-picker`?(0,g.jsx)(b,{accounts:e.accounts,loading:e.accountPickerLoading,error:e.pinError,onSelect:e.handleAccountSelect}):null}export{h as n,S as t};