@blamejs/blamejs-shop 0.4.17 → 0.4.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/admin.js CHANGED
@@ -68,6 +68,93 @@ var ACTIVITY_PANEL_LIMIT = 50;
68
68
  // and every write is drop-silent / fire-and-forget.
69
69
  var _errorLogSink = null;
70
70
 
71
+ // ---- multi-operator RBAC model -----------------------------------------
72
+ //
73
+ // A single shared ADMIN_API_KEY guarded the whole console historically.
74
+ // Per-operator identity layers on without weakening that gate: the
75
+ // ADMIN_API_KEY remains the bootstrap / break-glass credential, mapped to
76
+ // the OWNER role so an upgrade never locks the operator out. With zero
77
+ // operator rows the console behaves byte-for-byte as before — every check
78
+ // below resolves the ADMIN_API_KEY caller to owner, and owner holds every
79
+ // permission.
80
+ //
81
+ // Enforcement lives at the SAME chokepoint the bearer gate always used —
82
+ // the `_wrap` wrapper (and its `W`/`R` aliases). Every mutating route
83
+ // already names its audit action (`W("product.update", ...)`); the action
84
+ // is mapped to a required permission, so read-only operators are denied on
85
+ // the verb itself, not merely hidden in the nav. Read routes (`R`, no
86
+ // audit action) demand no permission beyond a valid credential.
87
+
88
+ // The three built-in roles and their permission grants. `owner` holds the
89
+ // full set including operator management; `manager` covers catalog /
90
+ // orders / customers / marketing writes; `viewer` is read-only — it holds
91
+ // NO write permission, so every `W`-wrapped route refuses it. The role set
92
+ // is the v1-defensible surface; operators wanting finer-grained custom
93
+ // roles compose lib/operator-roles.js on top.
94
+ var OPERATOR_PERMISSIONS = Object.freeze([
95
+ "catalog.write", // products, variants, prices, media, inventory, collections, merchandising, marketing content
96
+ "orders.write", // orders, returns, exchanges, refunds, fulfilment, exports, quotes, gift cards
97
+ "customers.write", // customer records, segments, notes, store credit
98
+ "settings.write", // shop configuration
99
+ "operators.manage", // create / disable / re-role other operators
100
+ ]);
101
+
102
+ var ROLE_GRANTS = Object.freeze({
103
+ owner: Object.freeze(OPERATOR_PERMISSIONS.slice()),
104
+ manager: Object.freeze(["catalog.write", "orders.write", "customers.write"]),
105
+ viewer: Object.freeze([]),
106
+ });
107
+
108
+ // Map a `W(...)` audit-action's first segment to the permission it
109
+ // requires. Every mutating admin route is covered; an action whose prefix
110
+ // is not listed falls back to `catalog.write` (the broad merchandising
111
+ // grant) so a newly-added write route is gated rather than silently open.
112
+ var _ACTION_PERMISSION = Object.freeze({
113
+ // catalog / merchandising / marketing content
114
+ product: "catalog.write", variant: "catalog.write", price: "catalog.write",
115
+ catalog: "catalog.write", collection: "catalog.write", media: "catalog.write",
116
+ inventory: "catalog.write", search_ranking: "catalog.write",
117
+ search_suggestion: "catalog.write", trust_badge: "catalog.write",
118
+ gift: "catalog.write", preorder: "catalog.write", quantity_discount: "catalog.write",
119
+ auto_discount: "catalog.write", coupon_policy: "catalog.write",
120
+ promo_banner: "catalog.write", announcement: "catalog.write", blog: "catalog.write",
121
+ page: "catalog.write", help: "catalog.write", survey: "catalog.write",
122
+ hours: "catalog.write", delivery_holiday: "catalog.write",
123
+ delivery_transit: "catalog.write", tax_rate: "catalog.write",
124
+ shipping_zone: "catalog.write", payment_domain: "catalog.write",
125
+ webhook: "catalog.write", subscription_plan: "catalog.write", loyalty: "catalog.write",
126
+ // orders / fulfilment / post-purchase
127
+ order: "orders.write", return: "orders.write", exchange: "orders.write",
128
+ rating: "orders.write", review: "orders.write", question: "orders.write",
129
+ answer: "orders.write", pick_list: "orders.write", pickup: "orders.write",
130
+ export: "orders.write", tax_filing: "orders.write", quote: "orders.write",
131
+ gift_card: "orders.write", subscription: "orders.write",
132
+ cart_recovery_code: "orders.write", support: "orders.write",
133
+ // customers
134
+ customer: "customers.write", customer_segment: "customers.write",
135
+ // shop configuration
136
+ config: "settings.write",
137
+ // operator management (mounted by this feature)
138
+ operator: "operators.manage",
139
+ });
140
+
141
+ // Permission a `W(auditAction, ...)` route requires, derived from the
142
+ // action's first dotted segment. Unmapped prefixes default to the broad
143
+ // merchandising write so an un-mapped new route fails closed for viewers.
144
+ function _permissionForAction(auditAction) {
145
+ if (typeof auditAction !== "string" || !auditAction.length) return "catalog.write";
146
+ var seg = auditAction.split(".")[0];
147
+ return _ACTION_PERMISSION[seg] || "catalog.write";
148
+ }
149
+
150
+ // True when `role` grants `permission`. The owner role always grants
151
+ // everything; an unknown role grants nothing (fails closed).
152
+ function _roleGrants(role, permission) {
153
+ var grants = ROLE_GRANTS[role];
154
+ if (!grants) return false;
155
+ return grants.indexOf(permission) !== -1;
156
+ }
157
+
71
158
  // Per-request store for the double-submit CSRF token. The admin console is
72
159
  // container-only and has no locale ALS (unlike the storefront), so it gets
73
160
  // its own: a sync middleware in `mount()` seeds the request's `req.csrfToken`
@@ -368,12 +455,22 @@ function _secureForReq(req) {
368
455
  }
369
456
  function _adminCookieName(secure) { return secure ? ADMIN_COOKIE_NAME_SECURE : ADMIN_COOKIE_NAME; }
370
457
 
371
- function _setAdminCookie(req, res) {
458
+ function _setAdminCookie(req, res, operator) {
372
459
  var secure = _secureForReq(req);
373
- _adminJar().writeSealed(res, _adminCookieName(secure), JSON.stringify({
460
+ // The bootstrap / break-glass sign-in (ADMIN_API_KEY) sets `admin:true`
461
+ // with no operator identity → resolves to the owner role. A per-operator
462
+ // sign-in additionally carries the operator id + built-in role, so the
463
+ // resolver names WHO is acting and gates them on their role's grants.
464
+ var claims = {
374
465
  admin: true,
375
466
  exp: Date.now() + b.constants.TIME.hours(12),
376
- }), { expires: new Date(Date.now() + b.constants.TIME.hours(12)), secure: secure });
467
+ };
468
+ if (operator && typeof operator === "object" && operator.id) {
469
+ claims.operator_id = String(operator.id);
470
+ claims.role = String(operator.role || "viewer");
471
+ }
472
+ _adminJar().writeSealed(res, _adminCookieName(secure), JSON.stringify(claims),
473
+ { expires: new Date(Date.now() + b.constants.TIME.hours(12)), secure: secure });
377
474
  }
378
475
  function _clearAdminCookie(req, res) {
379
476
  // Expire-now must match the live request's protocol so the cleared
@@ -404,26 +501,104 @@ function _takeGiftCardReveal(req, res, id) {
404
501
  try { env = JSON.parse(raw); } catch (_e) { return null; }
405
502
  return (env && env.id === id && typeof env.code === "string") ? env.code : null;
406
503
  }
407
- function _adminCookieValid(req) {
504
+
505
+ // One-time reveal of a freshly-minted operator API key. Same Post/Redirect/
506
+ // Get reveal pattern as the gift-card code: the plaintext is stashed in a
507
+ // sealed, HttpOnly, /admin-scoped cookie and read exactly once on the
508
+ // redirect target — never placed in the URL / Location / history / access
509
+ // log. A reload after the reveal shows the operator row with no key.
510
+ var OPERATOR_KEY_REVEAL_COOKIE = "op_key_reveal";
511
+ function _stashOperatorKeyReveal(res, id, plaintext) {
512
+ _adminJar().writeSealed(res, OPERATOR_KEY_REVEAL_COOKIE, JSON.stringify({ id: id, key: plaintext }),
513
+ { expires: new Date(Date.now() + b.constants.TIME.hours(1)) });
514
+ }
515
+ function _takeOperatorKeyReveal(req, res) {
516
+ var raw = _adminJar().readSealed(req, OPERATOR_KEY_REVEAL_COOKIE);
517
+ if (raw === null) return null;
518
+ _adminJar().clear(res, OPERATOR_KEY_REVEAL_COOKIE);
519
+ var env;
520
+ try { env = JSON.parse(raw); } catch (_e) { return null; }
521
+ return (env && typeof env.id === "string" && typeof env.key === "string") ? env : null;
522
+ }
523
+ // Read the sealed admin-session cookie's claims, or null when absent /
524
+ // invalid / expired. Returns `{ admin, exp, operator_id?, role? }` so the
525
+ // resolver can name a per-operator session; `_adminCookieValid` keeps the
526
+ // boolean shape every existing caller expects.
527
+ function _adminCookieClaims(req) {
408
528
  // Resolve the prefixed name first and the bare name second so an admin
409
529
  // session set in either environment (or mid-rollout) is recognised.
410
530
  var raw = _adminJar().readSealed(req, ADMIN_COOKIE_NAME_SECURE);
411
531
  if (raw === null) raw = _adminJar().readSealed(req, ADMIN_COOKIE_NAME);
412
- if (raw === null) return false;
532
+ if (raw === null) return null;
413
533
  var env;
414
- try { env = JSON.parse(raw); } catch (_e) { return false; }
415
- return !!(env && env.admin === true && env.exp && env.exp > Date.now());
534
+ try { env = JSON.parse(raw); } catch (_e) { return null; }
535
+ if (!(env && env.admin === true && env.exp && env.exp > Date.now())) return null;
536
+ return env;
537
+ }
538
+ function _adminCookieValid(req) {
539
+ return !!_adminCookieClaims(req);
416
540
  }
417
541
 
418
542
  // HTML-page auth: a valid admin cookie OR the bearer token (so existing
419
543
  // tooling that sends the header still reaches the dashboard). Never
420
544
  // throws — a missing vault surfaces as "not authed" so the caller can
421
- // render the login form rather than 500.
545
+ // render the login form rather than 500. NOTE: this resolves only the
546
+ // CREDENTIAL — permission enforcement happens at the write chokepoint
547
+ // (`_wrap`), so a viewer reaching a read page here is correct.
422
548
  function _htmlAuthed(req, expectedToken) {
423
549
  if (_authOk(_readBearer(req), expectedToken)) return true;
424
550
  try { return _adminCookieValid(req); } catch (_e) { return false; }
425
551
  }
426
552
 
553
+ // Resolve the acting operator for a request. Returns an actor:
554
+ // { kind: "owner"|"operator", operator_id, role, via }
555
+ // or null when no valid credential is presented. Resolution order:
556
+ // 1. ADMIN_API_KEY bearer → owner (bootstrap / break-glass).
557
+ // 2. Per-operator API-key bearer → that operator (role from the row).
558
+ // 3. Admin session cookie → owner (bootstrap cookie) OR the operator
559
+ // named in the cookie's claims (role re-read from the live row so a
560
+ // mid-session role change / disable takes effect immediately).
561
+ // The ADMIN_API_KEY path is timing-safe and checked first so it always
562
+ // works even when the operator-accounts table is empty or unwired.
563
+ async function _resolveActor(req, authCtx) {
564
+ var bearer = _readBearer(req);
565
+ if (_authOk(bearer, authCtx.expectedToken)) {
566
+ return { kind: "owner", operator_id: "owner", role: "owner", via: "admin_api_key" };
567
+ }
568
+ var accounts = authCtx.operatorAccounts;
569
+ // Per-operator bearer API key. timingSafeEqual lives inside verifyApiKey,
570
+ // and an unknown key burns the same compare as a known one.
571
+ if (bearer && accounts) {
572
+ var byKey = null;
573
+ try { byKey = await accounts.verifyApiKey(bearer); } catch (_e) { byKey = null; }
574
+ if (byKey) {
575
+ return { kind: "operator", operator_id: byKey.id, role: byKey.role, via: "operator_api_key" };
576
+ }
577
+ }
578
+ // Session cookie.
579
+ var claims = null;
580
+ try { claims = _adminCookieClaims(req); } catch (_e) { claims = null; }
581
+ if (!claims) return null;
582
+ if (!claims.operator_id) {
583
+ // Bootstrap cookie — ADMIN_API_KEY pasted in the browser, owner role.
584
+ return { kind: "owner", operator_id: "owner", role: "owner", via: "admin_cookie" };
585
+ }
586
+ // Per-operator session. Re-read the live row so a role change or a
587
+ // disable applied mid-session is honoured on the very next request
588
+ // rather than waiting for the 12h cookie to expire.
589
+ if (accounts) {
590
+ var live = null;
591
+ try { live = await accounts.getById(claims.operator_id); } catch (_e) { live = null; }
592
+ if (!live || live.status !== "active") return null;
593
+ return { kind: "operator", operator_id: live.id, role: live.role, via: "operator_cookie" };
594
+ }
595
+ // No accounts handle wired but the cookie names an operator — treat the
596
+ // sealed claim's role as authoritative (the cookie is vault-sealed, so it
597
+ // can't be forged); fail closed to viewer if the role is unexpected.
598
+ var role = ROLE_GRANTS[claims.role] ? claims.role : "viewer";
599
+ return { kind: "operator", operator_id: String(claims.operator_id), role: role, via: "operator_cookie" };
600
+ }
601
+
427
602
  function _problem(res, status, code, detail) {
428
603
  return b.problemDetails.send(res, {
429
604
  type: "/problems/" + code,
@@ -517,13 +692,54 @@ function _safeNotice(e, auditAction) {
517
692
  }
518
693
 
519
694
  function _wrap(handler, opts) {
520
- // Every admin handler routes through this wrapper: bearer-token
521
- // gate, error-to-problem-details translation, audit write on the
522
- // mutating ops. `opts.audit` is the audit action name; omit for
523
- // read-only routes.
695
+ // Every admin handler routes through this wrapper — the ONE chokepoint
696
+ // where the admin credential is checked. It resolves the acting operator
697
+ // (owner via ADMIN_API_KEY, or a per-operator key / session), enforces
698
+ // the route's permission on the mutating ops, translates thrown errors
699
+ // to problem-details, and writes the success audit. `opts.audit` is the
700
+ // audit action name (present on writes, absent on read-only routes);
701
+ // `opts.authCtx` carries the expected token + the operator-accounts
702
+ // handle + the audit-log peer.
703
+ var authCtx = opts.authCtx || { expectedToken: opts.expectedToken };
524
704
  return async function (req, res) {
525
- var token = _readBearer(req);
526
- if (!_authOk(token, opts.expectedToken)) return _problem(res, 401, "unauthorized");
705
+ var actor = await _resolveActor(req, authCtx);
706
+ if (!actor) return _problem(res, 401, "unauthorized");
707
+ // Permission gate — only mutating routes (those naming an audit
708
+ // action) require a write permission. Read routes (R, no audit
709
+ // action) admit any authenticated operator, viewer included. The
710
+ // owner role always passes; a viewer holds no write grant, so every
711
+ // W-wrapped route refuses it on the verb itself — not merely hidden
712
+ // in the nav.
713
+ if (opts.audit) {
714
+ var permission = _permissionForAction(opts.audit);
715
+ if (!_roleGrants(actor.role, permission)) {
716
+ // Audit the denied attempt through the chained operator log when
717
+ // wired (drop-silent — a recording failure must never change the
718
+ // 403 the operator sees), plus the framework audit sink.
719
+ if (authCtx.operatorAuditLog && typeof authCtx.operatorAuditLog.record === "function") {
720
+ try {
721
+ await authCtx.operatorAuditLog.record({
722
+ actor_type: "operator",
723
+ actor_id: actor.operator_id,
724
+ action: "permission.denied:" + opts.audit,
725
+ resource_kind: "operator_permission",
726
+ resource_id: permission,
727
+ before: null,
728
+ after: { role: actor.role, required: permission },
729
+ });
730
+ } catch (_e) { /* drop-silent */ }
731
+ }
732
+ b.audit.safeEmit({
733
+ action: AUDIT_NAMESPACE + ".permission.denied",
734
+ outcome: "failure",
735
+ metadata: { action: opts.audit, role: actor.role, required: permission },
736
+ });
737
+ return _problem(res, 403, "forbidden", "Your role does not permit this action.");
738
+ }
739
+ }
740
+ // Expose the resolved actor so a handler can stamp WHO into a downstream
741
+ // record (operator-management routes read this).
742
+ req.operatorActor = actor;
527
743
  try {
528
744
  var result = await handler(req, res);
529
745
  if (opts.audit && result && result !== false) {
@@ -619,13 +835,24 @@ function mount(router, deps) {
619
835
  var errorLog = deps.errorLog || null;
620
836
  _errorLogSink = errorLog;
621
837
 
838
+ // Multi-operator staff console. When `deps.operatorAccounts` is wired the
839
+ // /admin/operators screen mounts, per-operator credentials authenticate
840
+ // alongside ADMIN_API_KEY, and the role gate enforces read-only / scoped
841
+ // operators on the write chokepoint. ADMIN_API_KEY ALWAYS works as the
842
+ // bootstrap / break-glass owner credential regardless — with no operator
843
+ // rows wired or present, the console behaves exactly as before. The
844
+ // operator-audit-log peer (defaults ON when the framework audit chain is
845
+ // booted) chains every operator-management action + role-denied attempt.
846
+ var operatorAccounts = deps.operatorAccounts || null;
847
+ var operatorAuditLog = deps.operatorAuditLog || null;
848
+
622
849
  // Which optional console sections are wired — gates their nav links so a
623
850
  // signed-in admin is never sent to a route that wasn't mounted. Passed
624
851
  // into every authed render call as `nav_available`.
625
852
  // `reports` is always present in the nav (read-only sales summary needs no
626
853
  // extra dep); its route mounts unconditionally and renders an unconfigured
627
854
  // notice when the salesReports primitive isn't wired.
628
- var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns };
855
+ var navAvailable = { analytics: !!deps.analytics, returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, promoBanners: !!deps.promoBanners, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs, quotes: !!deps.quotes, emailCampaigns: !!emailCampaigns, operators: !!operatorAccounts };
629
856
 
630
857
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
631
858
 
@@ -647,26 +874,90 @@ function mount(router, deps) {
647
874
  });
648
875
  }
649
876
 
877
+ // The auth context every wrapped route resolves through — the bootstrap
878
+ // token plus the per-operator credential store + the chained audit peer.
879
+ // Threaded into `_wrap` so the single chokepoint owns BOTH the credential
880
+ // resolution and the role gate.
881
+ var authCtx = {
882
+ expectedToken: expectedToken,
883
+ operatorAccounts: operatorAccounts,
884
+ operatorAuditLog: operatorAuditLog,
885
+ };
886
+
650
887
  var W = function (auditAction, h) {
651
- return _wrap(h, { expectedToken: expectedToken, audit: auditAction });
888
+ var wrapped = _wrap(h, { authCtx: authCtx, audit: auditAction });
889
+ // Tag the wrapped fn with its audit action so `_pageOrApi` can derive
890
+ // the same write permission for the browser-form (cookie) branch
891
+ // without each route having to restate it.
892
+ wrapped._adminWriteAction = auditAction;
893
+ return wrapped;
652
894
  };
653
895
  var R = function (h) {
654
- return _wrap(h, { expectedToken: expectedToken });
896
+ return _wrap(h, { authCtx: authCtx });
655
897
  };
656
898
 
657
899
  // Content-negotiate one endpoint between the JSON API and the HTML
658
- // console: a bearer token routes to `apiHandler` (the JSON contract,
659
- // unchanged for tooling); a browser admin-cookie session routes to
660
- // `htmlHandler` (the rendered console page). Unauthenticated GETs show
661
- // the sign-in form; other methods bounce to /admin.
900
+ // console: a bearer credential (ADMIN_API_KEY or a per-operator key)
901
+ // routes to `apiHandler` (the JSON contract, unchanged for tooling); a
902
+ // browser admin-cookie session routes to `htmlHandler` (the rendered
903
+ // console page). Unauthenticated GETs show the sign-in form; other
904
+ // methods bounce to /admin. The actor is stamped onto `req` so the html
905
+ // handler can render WHO is signed in. NOTE: `_pageOrApi` wraps GET
906
+ // reads (and a handful of negotiated POSTs whose page form posts a
907
+ // create); the inner apiHandler is itself a `W(...)`/`R(...)` wrap, so
908
+ // the permission gate still fires on the write — this resolver only
909
+ // picks the response SHAPE, never bypasses the gate.
910
+ // The html/cookie branch of a negotiated POST runs the mutation inline,
911
+ // so the role gate (which lives in the JSON apiHandler's W-wrap) would
912
+ // otherwise be skipped on the browser-form path. `W(...)` tags its
913
+ // wrapped fn with `_adminWriteAction`; reading it here applies the SAME
914
+ // permission to the cookie branch with zero per-route restatement. A
915
+ // negotiated GET passes a plain (read) apiHandler with no tag → no gate.
662
916
  function _pageOrApi(isGet, apiHandler, htmlHandler) {
917
+ var writeAction = (apiHandler && apiHandler._adminWriteAction) || null;
663
918
  return async function (req, res) {
664
- if (_authOk(_readBearer(req), expectedToken)) return apiHandler(req, res);
919
+ var bearer = _readBearer(req);
920
+ // Any valid bearer (owner or operator) gets the JSON contract.
921
+ if (_authOk(bearer, expectedToken)) return apiHandler(req, res);
922
+ if (bearer && operatorAccounts) {
923
+ var byKey = null;
924
+ try { byKey = await operatorAccounts.verifyApiKey(bearer); } catch (_e) { byKey = null; }
925
+ if (byKey) return apiHandler(req, res);
926
+ }
665
927
  // Mirror _htmlAuthed: a missing vault makes the cookie check throw;
666
928
  // treat that as "not authed" rather than 500-ing the route.
667
929
  var cookieOk = false;
668
930
  try { cookieOk = _adminCookieValid(req); } catch (_e) { cookieOk = false; }
669
- if (cookieOk) return htmlHandler(req, res);
931
+ if (cookieOk) {
932
+ // Role gate on the browser-form mutation path — a viewer reaching a
933
+ // create/edit form POST is refused on the verb, not merely hidden in
934
+ // the nav. Resolve the actor and check the write permission; deny
935
+ // with a 403 page when the role doesn't grant it.
936
+ if (writeAction) {
937
+ var actor = await _resolveActor(req, authCtx);
938
+ if (!actor) { return _redirect(res, "/admin"); }
939
+ var perm = _permissionForAction(writeAction);
940
+ if (!_roleGrants(actor.role, perm)) {
941
+ if (operatorAuditLog && typeof operatorAuditLog.record === "function") {
942
+ try {
943
+ await operatorAuditLog.record({
944
+ actor_type: "operator", actor_id: actor.operator_id,
945
+ action: "permission.denied:" + writeAction,
946
+ resource_kind: "operator_permission", resource_id: perm,
947
+ before: null, after: { role: actor.role, required: perm },
948
+ });
949
+ } catch (_e) { /* drop-silent */ }
950
+ }
951
+ b.audit.safeEmit({
952
+ action: AUDIT_NAMESPACE + ".permission.denied", outcome: "failure",
953
+ metadata: { action: writeAction, role: actor.role, required: perm },
954
+ });
955
+ return _sendHtml(res, 403, _renderAdminForbidden(deps.shop_name, navAvailable, perm));
956
+ }
957
+ req.operatorActor = actor;
958
+ }
959
+ return htmlHandler(req, res);
960
+ }
670
961
  if (isGet) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
671
962
  return _redirect(res, "/admin");
672
963
  };
@@ -12441,6 +12732,16 @@ function mount(router, deps) {
12441
12732
 
12442
12733
  router.post("/admin/setup", async function (req, res) {
12443
12734
  if (!_htmlAuthed(req, expectedToken)) return _redirect(res, "/admin");
12735
+ // Saving shop configuration is settings.write — owner only. A
12736
+ // manager/viewer reaching the form POST is refused on the verb.
12737
+ var setupActor = await _resolveActor(req, authCtx);
12738
+ if (!setupActor || !_roleGrants(setupActor.role, "settings.write")) {
12739
+ b.audit.safeEmit({
12740
+ action: AUDIT_NAMESPACE + ".permission.denied", outcome: "failure",
12741
+ metadata: { action: "config.put", role: setupActor ? setupActor.role : null, required: "settings.write" },
12742
+ });
12743
+ return _sendHtml(res, 403, _renderAdminForbidden(deps.shop_name, navAvailable, "settings.write"));
12744
+ }
12444
12745
  var body = req.body || {};
12445
12746
  var values = {
12446
12747
  shop_name: (typeof body.shop_name === "string" ? body.shop_name : "").trim(),
@@ -12483,6 +12784,241 @@ function mount(router, deps) {
12483
12784
  });
12484
12785
  }
12485
12786
 
12787
+ // ---- multi-operator staff console -----------------------------------
12788
+ //
12789
+ // Mounted only when the deployment wires `deps.operatorAccounts`. The
12790
+ // ADMIN_API_KEY caller (owner) bootstraps the first operator while authed
12791
+ // via the break-glass key; thereafter each operator has their own login.
12792
+ // Every mutation runs through `W("operator.*", ...)`, so the chokepoint
12793
+ // enforces the `operators.manage` permission (owner-only) and audits both
12794
+ // the action and any role-denied attempt — no per-route guard.
12795
+ if (operatorAccounts) {
12796
+ // The operator sign-in form — reachable unauthenticated (it IS the
12797
+ // sign-in). An already-signed-in operator is bounced to the dashboard.
12798
+ router.get("/admin/operators/signin", async function (req, res) {
12799
+ if (_htmlAuthed(req, expectedToken)) return _redirect(res, "/admin");
12800
+ _sendHtml(res, 200, renderOperatorSignin({ shop_name: deps.shop_name }));
12801
+ });
12802
+
12803
+ // Operator email+password sign-in. NOT permission-gated — this IS the
12804
+ // primary-credential challenge. On success it mints a per-operator
12805
+ // session cookie carrying the operator id + role, so the resolver names
12806
+ // WHO is acting on every subsequent request and gates them on their
12807
+ // role. A miss re-renders the form with a generic notice (no oracle on
12808
+ // which half — email vs password — failed).
12809
+ router.post("/admin/operators/signin", async function (req, res) {
12810
+ var body = req.body || {};
12811
+ var email = typeof body.email === "string" ? body.email : "";
12812
+ var pass = typeof body.password === "string" ? body.password : "";
12813
+ var account = null;
12814
+ try { account = await operatorAccounts.verifyPassword({ email: email, password: pass }); }
12815
+ catch (_e) { account = null; }
12816
+ if (!account) {
12817
+ return _sendHtml(res, 401, renderOperatorSignin({ shop_name: deps.shop_name, error: true }));
12818
+ }
12819
+ try { _setAdminCookie(req, res, account); }
12820
+ catch (e) {
12821
+ if (e && e.code === "vault/not-initialized") {
12822
+ return _sendHtml(res, 503, renderOperatorSignin({ shop_name: deps.shop_name }));
12823
+ }
12824
+ throw e;
12825
+ }
12826
+ // Chained audit of the successful staff sign-in when wired.
12827
+ if (operatorAuditLog && typeof operatorAuditLog.record === "function") {
12828
+ try {
12829
+ await operatorAuditLog.record({
12830
+ actor_type: "operator", actor_id: account.id,
12831
+ action: "operator.signin", resource_kind: "operator_account",
12832
+ resource_id: account.id, before: null, after: { role: account.role },
12833
+ });
12834
+ } catch (_e) { /* drop-silent */ }
12835
+ }
12836
+ _redirect(res, "/admin");
12837
+ });
12838
+
12839
+ // The operators console — list + create form. Negotiated: bearer →
12840
+ // JSON list, cookie → rendered page. The `R(...)`/list read admits any
12841
+ // operator with a valid credential, but the CREATE/disable/role
12842
+ // mutations below are gated on operators.manage, so a non-owner sees
12843
+ // the page but every action 403s.
12844
+ router.get("/admin/operators", _pageOrApi(true,
12845
+ R(async function (req, res) {
12846
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
12847
+ var status = url && url.searchParams.get("status");
12848
+ var listOpts = {};
12849
+ if (status === "active" || status === "disabled") listOpts.status = status;
12850
+ var rows = await operatorAccounts.listAccounts(listOpts);
12851
+ _json(res, 200, { rows: rows });
12852
+ return false;
12853
+ }),
12854
+ async function (req, res) {
12855
+ var rows = await operatorAccounts.listAccounts({});
12856
+ var created = null;
12857
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
12858
+ if (url && url.searchParams.get("created")) created = url.searchParams.get("created");
12859
+ // One-time API-key reveal (read-once, then cleared).
12860
+ var reveal = null;
12861
+ try { reveal = _takeOperatorKeyReveal(req, res); } catch (_e) { reveal = null; }
12862
+ _sendHtml(res, 200, renderOperators({
12863
+ shop_name: deps.shop_name, nav_available: navAvailable,
12864
+ operators: rows, created_id: created, reveal: reveal,
12865
+ actor: req.operatorActor || null,
12866
+ }));
12867
+ },
12868
+ ));
12869
+
12870
+ // Create an operator. The first operator is created by the ADMIN_API_KEY
12871
+ // owner; thereafter any owner can. `created_by` is the acting operator's
12872
+ // id (or the "owner" sentinel for the break-glass key).
12873
+ router.post("/admin/operators", _pageOrApi(false,
12874
+ W("operator.create", async function (req, res) {
12875
+ var body = req.body || {};
12876
+ var account = await operatorAccounts.createAccount({
12877
+ email: body.email,
12878
+ display_name: body.display_name,
12879
+ password: body.password,
12880
+ role: body.role,
12881
+ mint_api_key: body.mint_api_key === "1" || body.mint_api_key === "on" || body.mint_api_key === true,
12882
+ created_by: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12883
+ });
12884
+ _json(res, 201, account);
12885
+ return account;
12886
+ }),
12887
+ async function (req, res) {
12888
+ var body = req.body || {};
12889
+ var made;
12890
+ try {
12891
+ made = await operatorAccounts.createAccount({
12892
+ email: body.email,
12893
+ display_name: body.display_name,
12894
+ password: body.password,
12895
+ role: body.role,
12896
+ mint_api_key: body.mint_api_key === "1" || body.mint_api_key === "on",
12897
+ created_by: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12898
+ });
12899
+ } catch (e) {
12900
+ var n = _safeNotice(e, "operator.create");
12901
+ var rows = await operatorAccounts.listAccounts({});
12902
+ return _sendHtml(res, n.status, renderOperators({
12903
+ shop_name: deps.shop_name, nav_available: navAvailable,
12904
+ operators: rows, notice: n.message, actor: req.operatorActor || null,
12905
+ }));
12906
+ }
12907
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".operator.create", outcome: "success", metadata: { id: made.id } });
12908
+ // The freshly-minted API key (if any) is shown ONCE on the redirect
12909
+ // target via a one-time sealed reveal cookie — never in the URL.
12910
+ if (made.api_key) _stashOperatorKeyReveal(res, made.id, made.api_key);
12911
+ _redirect(res, "/admin/operators?created=" + encodeURIComponent(made.id));
12912
+ },
12913
+ ));
12914
+
12915
+ // Disable an operator (the v1 revoke). Their password + API key stop
12916
+ // authenticating on the next request; a live cookie session is refused
12917
+ // by the resolver's live-row re-read.
12918
+ router.post("/admin/operators/:id/disable", _pageOrApi(false,
12919
+ W("operator.disable", async function (req, res) {
12920
+ var out = await operatorAccounts.setStatus({
12921
+ id: req.params.id, status: "disabled",
12922
+ actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12923
+ });
12924
+ _json(res, 200, out);
12925
+ return out;
12926
+ }),
12927
+ _operatorActionHtml(function (req) {
12928
+ return operatorAccounts.setStatus({
12929
+ id: req.params.id, status: "disabled",
12930
+ actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12931
+ });
12932
+ }, "operator.disable"),
12933
+ ));
12934
+
12935
+ router.post("/admin/operators/:id/enable", _pageOrApi(false,
12936
+ W("operator.enable", async function (req, res) {
12937
+ var out = await operatorAccounts.setStatus({
12938
+ id: req.params.id, status: "active",
12939
+ actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12940
+ });
12941
+ _json(res, 200, out);
12942
+ return out;
12943
+ }),
12944
+ _operatorActionHtml(function (req) {
12945
+ return operatorAccounts.setStatus({
12946
+ id: req.params.id, status: "active",
12947
+ actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12948
+ });
12949
+ }, "operator.enable"),
12950
+ ));
12951
+
12952
+ // Change an operator's built-in role.
12953
+ router.post("/admin/operators/:id/role", _pageOrApi(false,
12954
+ W("operator.set_role", async function (req, res) {
12955
+ var out = await operatorAccounts.setRole({
12956
+ id: req.params.id, role: (req.body || {}).role,
12957
+ actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12958
+ });
12959
+ _json(res, 200, out);
12960
+ return out;
12961
+ }),
12962
+ _operatorActionHtml(function (req) {
12963
+ return operatorAccounts.setRole({
12964
+ id: req.params.id, role: (req.body || {}).role,
12965
+ actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12966
+ });
12967
+ }, "operator.set_role"),
12968
+ ));
12969
+
12970
+ // Rotate (or first-mint) an operator's per-operator API key. The new
12971
+ // plaintext is revealed once on the redirect target.
12972
+ router.post("/admin/operators/:id/rotate-key", _pageOrApi(false,
12973
+ W("operator.rotate_key", async function (req, res) {
12974
+ var out = await operatorAccounts.rotateApiKey({
12975
+ id: req.params.id,
12976
+ actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12977
+ });
12978
+ _json(res, 200, out);
12979
+ return out;
12980
+ }),
12981
+ async function (req, res) {
12982
+ var out;
12983
+ try {
12984
+ out = await operatorAccounts.rotateApiKey({
12985
+ id: req.params.id,
12986
+ actor_id: (req.operatorActor && req.operatorActor.operator_id) || "owner",
12987
+ });
12988
+ } catch (e) {
12989
+ var n = _safeNotice(e, "operator.rotate_key");
12990
+ var rows = await operatorAccounts.listAccounts({});
12991
+ return _sendHtml(res, n.status, renderOperators({
12992
+ shop_name: deps.shop_name, nav_available: navAvailable,
12993
+ operators: rows, notice: n.message, actor: req.operatorActor || null,
12994
+ }));
12995
+ }
12996
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".operator.rotate_key", outcome: "success", metadata: { id: out.id } });
12997
+ if (out.api_key) _stashOperatorKeyReveal(res, out.id, out.api_key);
12998
+ _redirect(res, "/admin/operators?created=" + encodeURIComponent(out.id));
12999
+ },
13000
+ ));
13001
+ }
13002
+
13003
+ // Shared html-handler factory for the simple operator lifecycle POSTs
13004
+ // (disable / enable / role) — run the mutation, surface a bad-input
13005
+ // notice as a re-rendered page, otherwise PRG back to the list.
13006
+ function _operatorActionHtml(mutate, auditAction) {
13007
+ return async function (req, res) {
13008
+ try { await mutate(req); }
13009
+ catch (e) {
13010
+ var n = _safeNotice(e, auditAction);
13011
+ var rows = await operatorAccounts.listAccounts({});
13012
+ return _sendHtml(res, n.status, renderOperators({
13013
+ shop_name: deps.shop_name, nav_available: navAvailable,
13014
+ operators: rows, notice: n.message, actor: req.operatorActor || null,
13015
+ }));
13016
+ }
13017
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditAction, outcome: "success", metadata: { id: req.params.id } });
13018
+ _redirect(res, "/admin/operators");
13019
+ };
13020
+ }
13021
+
12486
13022
  // ---- ping (auth check) ----------------------------------------------
12487
13023
 
12488
13024
  router.get("/admin/ping", R(async function (_req, res) {
@@ -12853,6 +13389,7 @@ var ADMIN_NAV_ITEMS = [
12853
13389
  { key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
12854
13390
  { key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
12855
13391
  { key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
13392
+ { key: "operators", href: "/admin/operators", label: "Operators", requires: "operators" },
12856
13393
  { key: "integrations", href: "/admin/integrations", label: "Integrations" },
12857
13394
  { key: "setup", href: "/admin/setup", label: "Setup" },
12858
13395
  ];
@@ -12918,6 +13455,26 @@ function renderAdminConfirm(opts) {
12918
13455
  return _renderAdminShell(opts.shop_name, opts.heading || "Confirm", body, opts.active, opts.nav_available);
12919
13456
  }
12920
13457
 
13458
+ // 403 page shown when a signed-in operator's role does not grant the
13459
+ // permission a browser-form mutation requires. The required permission is
13460
+ // stated plainly so the operator knows what to ask the owner for. `perm`
13461
+ // is one of the closed OPERATOR_PERMISSIONS tokens (never untrusted), but
13462
+ // it is escaped anyway to keep the render escape-by-default.
13463
+ function _renderAdminForbidden(shopName, navAvailable, perm) {
13464
+ var name = shopName || "blamejs.shop";
13465
+ var body =
13466
+ "<section class=\"mw-42\">" +
13467
+ "<h2>Not permitted</h2>" +
13468
+ "<div class=\"banner banner--err\">Your role does not permit this action.</div>" +
13469
+ "<div class=\"panel\">" +
13470
+ "<p>This action requires the <code>" + _htmlEscape(perm || "") + "</code> permission. " +
13471
+ "Ask an owner to grant your account a role that includes it.</p>" +
13472
+ "<a class=\"btn btn--ghost\" href=\"/admin\">Back to dashboard</a>" +
13473
+ "</div>" +
13474
+ "</section>";
13475
+ return _renderAdminShell(name, "Not permitted", body, null, navAvailable);
13476
+ }
13477
+
12921
13478
  function renderAdminLogin(opts) {
12922
13479
  opts = opts || {};
12923
13480
  var err = opts.error
@@ -12938,11 +13495,142 @@ function renderAdminLogin(opts) {
12938
13495
  "</label>" +
12939
13496
  "<button type=\"submit\" class=\"btn\">Sign in</button>" +
12940
13497
  "</form>" +
13498
+ "<p class=\"signin-lede\"><a href=\"/admin/operators/signin\">Sign in as an operator &rarr;</a></p>" +
12941
13499
  "</div>" +
12942
13500
  "</div>";
12943
13501
  return _renderAdminShell(shopName, "Sign in", body, null);
12944
13502
  }
12945
13503
 
13504
+ // Per-operator email + password sign-in form. Distinct from the
13505
+ // ADMIN_API_KEY sign-in (renderAdminLogin) — that one is the bootstrap /
13506
+ // break-glass owner credential; this is the everyday staff login.
13507
+ function renderOperatorSignin(opts) {
13508
+ opts = opts || {};
13509
+ var err = opts.error
13510
+ ? "<div class=\"banner banner--err\">Those credentials didn't match, or the account is disabled.</div>"
13511
+ : "";
13512
+ var shopName = opts.shop_name || "blamejs.shop";
13513
+ var body =
13514
+ "<div class=\"signin-wrap\">" +
13515
+ "<div class=\"signin-card\">" +
13516
+ "<div class=\"signin-mark\"><span class=\"dot\"></span>" + _htmlEscape(shopName) + " admin</div>" +
13517
+ "<h2>Operator sign in</h2>" +
13518
+ "<p class=\"signin-lede\">Sign in with your operator email and password.</p>" +
13519
+ err +
13520
+ "<form method=\"post\" action=\"/admin/operators/signin\">" +
13521
+ "<label class=\"form-field\"><span>Email</span>" +
13522
+ "<input type=\"email\" name=\"email\" autocomplete=\"username\" autofocus required>" +
13523
+ "</label>" +
13524
+ "<label class=\"form-field\"><span>Password</span>" +
13525
+ "<input type=\"password\" name=\"password\" autocomplete=\"current-password\" required>" +
13526
+ "</label>" +
13527
+ "<button type=\"submit\" class=\"btn\">Sign in</button>" +
13528
+ "</form>" +
13529
+ "<p class=\"signin-lede\"><a href=\"/admin\">Use the admin API key instead &rarr;</a></p>" +
13530
+ "</div>" +
13531
+ "</div>";
13532
+ return _renderAdminShell(shopName, "Operator sign in", body, null);
13533
+ }
13534
+
13535
+ // The operators console: the roster table + the create form. Owner-only
13536
+ // actions (create / disable / re-role / rotate key) render for every
13537
+ // signed-in operator, but the role gate at the chokepoint 403s a non-owner
13538
+ // who submits one — the page never lies about authority by hiding the
13539
+ // controls, it relies on the verb-level gate. Every operator-authored
13540
+ // string (email / display name) is esc()'d on render.
13541
+ function renderOperators(opts) {
13542
+ opts = opts || {};
13543
+ var shopName = opts.shop_name || "blamejs.shop";
13544
+ var operators = Array.isArray(opts.operators) ? opts.operators : [];
13545
+ var notice = opts.notice
13546
+ ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>"
13547
+ : "";
13548
+
13549
+ // One-time API-key reveal panel (Post/Redirect/Get — shown once).
13550
+ var reveal = "";
13551
+ if (opts.reveal && typeof opts.reveal.key === "string") {
13552
+ reveal =
13553
+ "<div class=\"banner banner--warn\">" +
13554
+ "<strong>New API key — copy it now.</strong> It is shown once and never again.<br>" +
13555
+ "<code>" + _htmlEscape(opts.reveal.key) + "</code>" +
13556
+ "</div>";
13557
+ } else if (opts.created_id) {
13558
+ reveal = "<div class=\"banner banner--ok\">Operator saved.</div>";
13559
+ }
13560
+
13561
+ var rows = operators.length
13562
+ ? operators.map(function (o) {
13563
+ var idShort = _htmlEscape(String(o.id || "").slice(0, 8));
13564
+ var statusPill = o.status === "disabled"
13565
+ ? "<span class=\"status-pill cancelled\">disabled</span>"
13566
+ : "<span class=\"status-pill paid\">active</span>";
13567
+ var roleOptions = ["owner", "manager", "viewer"].map(function (r) {
13568
+ return "<option value=\"" + r + "\"" + (o.role === r ? " selected" : "") + ">" + r + "</option>";
13569
+ }).join("");
13570
+ var roleForm =
13571
+ "<form method=\"post\" action=\"/admin/operators/" + _htmlEscape(o.id) + "/role\" class=\"form-inline\">" +
13572
+ "<select name=\"role\" aria-label=\"Role\">" + roleOptions + "</select>" +
13573
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Set role</button>" +
13574
+ "</form>";
13575
+ var statusForm = o.status === "disabled"
13576
+ ? "<form method=\"post\" action=\"/admin/operators/" + _htmlEscape(o.id) + "/enable\" class=\"form-inline\">" +
13577
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Enable</button></form>"
13578
+ : "<form method=\"post\" action=\"/admin/operators/" + _htmlEscape(o.id) + "/disable\" class=\"form-inline\">" +
13579
+ "<button class=\"btn btn--danger btn--sm\" type=\"submit\">Disable</button></form>";
13580
+ var keyForm =
13581
+ "<form method=\"post\" action=\"/admin/operators/" + _htmlEscape(o.id) + "/rotate-key\" class=\"form-inline\">" +
13582
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">" + (o.has_api_key ? "Rotate key" : "Mint key") + "</button>" +
13583
+ "</form>";
13584
+ return "<tr>" +
13585
+ "<td>" + _htmlEscape(o.display_name) + "<br><span class=\"meta\">" + _htmlEscape(o.email) + "</span></td>" +
13586
+ "<td><span class=\"meta\">" + idShort + "</span></td>" +
13587
+ "<td>" + roleForm + "</td>" +
13588
+ "<td>" + statusPill + "</td>" +
13589
+ "<td>" + (o.has_api_key ? "yes" : "no") + "</td>" +
13590
+ "<td class=\"actions-row\">" + statusForm + keyForm + "</td>" +
13591
+ "</tr>";
13592
+ }).join("")
13593
+ : "<tr><td colspan=\"6\" class=\"empty\">No operators yet. The admin API key is the owner until you add one.</td></tr>";
13594
+
13595
+ var table = _tableWrap(
13596
+ "<table><thead><tr>" +
13597
+ "<th scope=\"col\">Operator</th><th scope=\"col\">ID</th><th scope=\"col\">Role</th>" +
13598
+ "<th scope=\"col\">Status</th><th scope=\"col\">API key</th><th scope=\"col\">Actions</th>" +
13599
+ "</tr></thead><tbody>" + rows + "</tbody></table>");
13600
+
13601
+ var createForm =
13602
+ "<section><h2>Add an operator</h2><div class=\"panel\">" +
13603
+ "<form method=\"post\" action=\"/admin/operators\">" +
13604
+ "<label class=\"form-field\"><span>Display name</span>" +
13605
+ "<input type=\"text\" name=\"display_name\" maxlength=\"128\" required></label>" +
13606
+ "<label class=\"form-field\"><span>Email</span>" +
13607
+ "<input type=\"email\" name=\"email\" autocomplete=\"off\" required></label>" +
13608
+ "<label class=\"form-field\"><span>Password</span>" +
13609
+ "<input type=\"password\" name=\"password\" autocomplete=\"new-password\" minlength=\"12\" required>" +
13610
+ "<small>At least 12 characters. Hashed with Argon2id — never stored in the clear.</small></label>" +
13611
+ "<label class=\"form-field\"><span>Role</span>" +
13612
+ "<select name=\"role\">" +
13613
+ "<option value=\"manager\">manager — catalog, orders, customers, marketing</option>" +
13614
+ "<option value=\"viewer\">viewer — read-only</option>" +
13615
+ "<option value=\"owner\">owner — everything, including operators</option>" +
13616
+ "</select></label>" +
13617
+ "<label class=\"form-field form-field--check\">" +
13618
+ "<input type=\"checkbox\" name=\"mint_api_key\" value=\"1\"> " +
13619
+ "<span>Also mint a bearer API key (for CLI / automation)</span></label>" +
13620
+ "<button type=\"submit\" class=\"btn\">Create operator</button>" +
13621
+ "</form>" +
13622
+ "</div></section>";
13623
+
13624
+ var body =
13625
+ "<section><h2>Operators</h2>" +
13626
+ notice + reveal +
13627
+ "<p class=\"signin-lede\">The admin API key is always the break-glass owner. Add operators so each person signs in with their own credential and role.</p>" +
13628
+ "<div class=\"panel\">" + table + "</div>" +
13629
+ "</section>" + createForm;
13630
+
13631
+ return _renderAdminShell(shopName, "Operators", body, "operators", opts.nav_available);
13632
+ }
13633
+
12946
13634
  function renderAdminLanding(opts) {
12947
13635
  opts = opts || {};
12948
13636
  var setupBanner = opts.setup_complete
@@ -20914,4 +21602,6 @@ module.exports = {
20914
21602
  renderAdminPickupLocations: renderAdminPickupLocations,
20915
21603
  renderAdminPickups: renderAdminPickups,
20916
21604
  renderAdminGiftWraps: renderAdminGiftWraps,
21605
+ renderOperators: renderOperators,
21606
+ renderOperatorSignin: renderOperatorSignin,
20917
21607
  };