@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/CHANGELOG.md +2 -0
- package/README.md +1 -1
- package/SECURITY.md +11 -0
- package/lib/admin.js +713 -23
- package/lib/asset-manifest.json +1 -1
- package/lib/index.js +1 -0
- package/lib/operator-accounts.js +543 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
532
|
+
if (raw === null) return null;
|
|
413
533
|
var env;
|
|
414
|
-
try { env = JSON.parse(raw); } catch (_e) { return
|
|
415
|
-
|
|
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
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
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
|
|
526
|
-
if (!
|
|
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
|
-
|
|
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, {
|
|
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
|
|
659
|
-
// unchanged for tooling); a
|
|
660
|
-
// `htmlHandler` (the rendered
|
|
661
|
-
// the sign-in form; other
|
|
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
|
-
|
|
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)
|
|
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 →</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 →</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
|
};
|