@blamejs/blamejs-shop 0.4.16 → 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 +4 -0
- package/README.md +1 -1
- package/SECURITY.md +11 -0
- package/lib/admin.js +1149 -23
- package/lib/asset-manifest.json +1 -1
- package/lib/email-campaigns.js +799 -9
- package/lib/index.js +1 -0
- package/lib/operator-accounts.js +543 -0
- package/lib/security-middleware.js +1 -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) {
|
|
@@ -604,6 +820,8 @@ function mount(router, deps) {
|
|
|
604
820
|
var stockTransfers = deps.stockTransfers || null; // location→location transfer console (dispatch/receive FSM) disabled when absent
|
|
605
821
|
var inventoryWriteoffs = deps.inventoryWriteoffs || null; // reason-coded write-off / shrinkage console disabled when absent
|
|
606
822
|
var quotes = deps.quotes || null; // RFQ negotiation console (queue/detail/respond/withdraw/convert) disabled when absent
|
|
823
|
+
var emailCampaigns = deps.emailCampaigns || null; // consent-gated broadcast/campaign console disabled when absent
|
|
824
|
+
var mailingAudiences = deps.mailingAudiences || null; // audience picker for the campaign console (target-an-audience dropdown)
|
|
607
825
|
// Read-only activity log at /admin/audit. Defaults ON — the framework
|
|
608
826
|
// audit chain is always booted by createApp, so the screen always has a
|
|
609
827
|
// data source (unlike the optional primitives above, which default off).
|
|
@@ -617,13 +835,24 @@ function mount(router, deps) {
|
|
|
617
835
|
var errorLog = deps.errorLog || null;
|
|
618
836
|
_errorLogSink = errorLog;
|
|
619
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
|
+
|
|
620
849
|
// Which optional console sections are wired — gates their nav links so a
|
|
621
850
|
// signed-in admin is never sent to a route that wasn't mounted. Passed
|
|
622
851
|
// into every authed render call as `nav_available`.
|
|
623
852
|
// `reports` is always present in the nav (read-only sales summary needs no
|
|
624
853
|
// extra dep); its route mounts unconditionally and renders an unconfigured
|
|
625
854
|
// notice when the salesReports primitive isn't wired.
|
|
626
|
-
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 };
|
|
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 };
|
|
627
856
|
|
|
628
857
|
try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
|
|
629
858
|
|
|
@@ -645,26 +874,90 @@ function mount(router, deps) {
|
|
|
645
874
|
});
|
|
646
875
|
}
|
|
647
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
|
+
|
|
648
887
|
var W = function (auditAction, h) {
|
|
649
|
-
|
|
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;
|
|
650
894
|
};
|
|
651
895
|
var R = function (h) {
|
|
652
|
-
return _wrap(h, {
|
|
896
|
+
return _wrap(h, { authCtx: authCtx });
|
|
653
897
|
};
|
|
654
898
|
|
|
655
899
|
// Content-negotiate one endpoint between the JSON API and the HTML
|
|
656
|
-
// console: a bearer
|
|
657
|
-
// unchanged for tooling); a
|
|
658
|
-
// `htmlHandler` (the rendered
|
|
659
|
-
// 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.
|
|
660
916
|
function _pageOrApi(isGet, apiHandler, htmlHandler) {
|
|
917
|
+
var writeAction = (apiHandler && apiHandler._adminWriteAction) || null;
|
|
661
918
|
return async function (req, res) {
|
|
662
|
-
|
|
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
|
+
}
|
|
663
927
|
// Mirror _htmlAuthed: a missing vault makes the cookie check throw;
|
|
664
928
|
// treat that as "not authed" rather than 500-ing the route.
|
|
665
929
|
var cookieOk = false;
|
|
666
930
|
try { cookieOk = _adminCookieValid(req); } catch (_e) { cookieOk = false; }
|
|
667
|
-
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
|
+
}
|
|
668
961
|
if (isGet) return _sendHtml(res, 200, renderAdminLogin({ shop_name: deps.shop_name }));
|
|
669
962
|
return _redirect(res, "/admin");
|
|
670
963
|
};
|
|
@@ -5703,6 +5996,250 @@ function mount(router, deps) {
|
|
|
5703
5996
|
));
|
|
5704
5997
|
}
|
|
5705
5998
|
|
|
5999
|
+
// ---- email campaigns (consent-gated broadcast) ----------------------
|
|
6000
|
+
//
|
|
6001
|
+
// The marketing-broadcast console. An operator authors a campaign
|
|
6002
|
+
// (subject + Markdown body), targets an existing mailing audience, sees
|
|
6003
|
+
// the RESOLVED REACHABLE count (who is actually marketing-consented +
|
|
6004
|
+
// deliverable right now — never the raw membership), test-sends to their
|
|
6005
|
+
// own inbox, then sends. Consent is resolved AT SEND TIME, per recipient:
|
|
6006
|
+
// only a marketing-consented (newsletter-subscribed, not suppressed)
|
|
6007
|
+
// recipient with a deliverable plaintext address gets the broadcast, and
|
|
6008
|
+
// someone who unsubscribes after the send starts is honored mid-send.
|
|
6009
|
+
// Every broadcast carries an RFC 8058 one-click List-Unsubscribe header
|
|
6010
|
+
// pair plus an in-body unsubscribe link. The operator-authored body is
|
|
6011
|
+
// treated as hostile: it renders escape-by-default (any `<` lands as
|
|
6012
|
+
// `<`; links pass the https-only safeUrl gate) so a compromised admin
|
|
6013
|
+
// key can't inject script into mail or stored XSS into this console.
|
|
6014
|
+
if (emailCampaigns) {
|
|
6015
|
+
var _campaignAudiences = function () {
|
|
6016
|
+
if (!mailingAudiences) return Promise.resolve([]);
|
|
6017
|
+
return mailingAudiences.listAudiences({ include_archived: false });
|
|
6018
|
+
};
|
|
6019
|
+
|
|
6020
|
+
// List — every campaign with status + per-campaign delivery counts.
|
|
6021
|
+
// Content-negotiated: bearer → the JSON array; browser → the table.
|
|
6022
|
+
router.get("/admin/campaigns", _pageOrApi(true,
|
|
6023
|
+
R(async function (req, res) {
|
|
6024
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6025
|
+
var status = url && url.searchParams.get("status");
|
|
6026
|
+
var rows = await emailCampaigns.listCampaigns(status ? { status: status } : {});
|
|
6027
|
+
_json(res, 200, { campaigns: rows, can_broadcast: emailCampaigns.canBroadcast() });
|
|
6028
|
+
}),
|
|
6029
|
+
async function (req, res) {
|
|
6030
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6031
|
+
var rows = await emailCampaigns.listCampaigns({});
|
|
6032
|
+
// Roll each campaign's per-recipient send ledger up for the count
|
|
6033
|
+
// column — bounded read per row (the list is operator-scale).
|
|
6034
|
+
var withCounts = [];
|
|
6035
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
6036
|
+
var counts = null;
|
|
6037
|
+
try { counts = await emailCampaigns.sendCounts(rows[i].slug); }
|
|
6038
|
+
catch (_e) { counts = null; }
|
|
6039
|
+
withCounts.push({ campaign: rows[i], counts: counts });
|
|
6040
|
+
}
|
|
6041
|
+
_sendHtml(res, 200, renderAdminCampaigns({
|
|
6042
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
6043
|
+
rows: withCounts, can_broadcast: emailCampaigns.canBroadcast(),
|
|
6044
|
+
sent: url && url.searchParams.get("sent"),
|
|
6045
|
+
saved: url && url.searchParams.get("saved"),
|
|
6046
|
+
tested: url && url.searchParams.get("tested"),
|
|
6047
|
+
notice: (url && url.searchParams.get("err")) ? _campaignErrNotice(url.searchParams.get("err")) : null,
|
|
6048
|
+
}));
|
|
6049
|
+
},
|
|
6050
|
+
));
|
|
6051
|
+
|
|
6052
|
+
// New-campaign form — its own GET so a bad submit's err redirect
|
|
6053
|
+
// keeps the operator's context.
|
|
6054
|
+
router.get("/admin/campaigns/new", _pageOrApi(true,
|
|
6055
|
+
R(async function (_req, res) {
|
|
6056
|
+
_json(res, 200, { audiences: await _campaignAudiences() });
|
|
6057
|
+
}),
|
|
6058
|
+
async function (req, res) {
|
|
6059
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6060
|
+
_sendHtml(res, 200, renderAdminCampaignNew({
|
|
6061
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
6062
|
+
audiences: await _campaignAudiences(),
|
|
6063
|
+
notice: (url && url.searchParams.get("err")) ? _campaignErrNotice(url.searchParams.get("err")) : null,
|
|
6064
|
+
}));
|
|
6065
|
+
},
|
|
6066
|
+
));
|
|
6067
|
+
|
|
6068
|
+
function _campaignInput(body) {
|
|
6069
|
+
return {
|
|
6070
|
+
slug: body.slug,
|
|
6071
|
+
subject: body.subject,
|
|
6072
|
+
body_html: body.body_html,
|
|
6073
|
+
// Markdown source is the single authored body; the text alt is
|
|
6074
|
+
// derived from it at send time, so persist the same source in
|
|
6075
|
+
// both columns (the renderer produces the HTML + text views).
|
|
6076
|
+
body_text: body.body_text != null && body.body_text !== "" ? body.body_text : body.body_html,
|
|
6077
|
+
audience_slug: body.audience_slug,
|
|
6078
|
+
from_address: body.from_address,
|
|
6079
|
+
from_name: body.from_name,
|
|
6080
|
+
reply_to: body.reply_to != null && body.reply_to !== "" ? body.reply_to : undefined,
|
|
6081
|
+
};
|
|
6082
|
+
}
|
|
6083
|
+
|
|
6084
|
+
// Create — composes defineCampaign (validates slug / subject / body /
|
|
6085
|
+
// audience / sender identity, throws TypeError → 400 / err redirect).
|
|
6086
|
+
router.post("/admin/campaigns", _pageOrApi(false,
|
|
6087
|
+
W("email_campaign.create", async function (req, res) {
|
|
6088
|
+
var c;
|
|
6089
|
+
try { c = await emailCampaigns.defineCampaign(_campaignInput(req.body || {})); }
|
|
6090
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
6091
|
+
_json(res, 201, c);
|
|
6092
|
+
return { id: c.slug };
|
|
6093
|
+
}),
|
|
6094
|
+
async function (req, res) {
|
|
6095
|
+
try { await emailCampaigns.defineCampaign(_campaignInput(req.body || {})); }
|
|
6096
|
+
catch (e) {
|
|
6097
|
+
var n = _safeNotice(e, "email_campaign.create");
|
|
6098
|
+
if (n.status >= 500) throw e;
|
|
6099
|
+
return _redirect(res, "/admin/campaigns/new?err=bad");
|
|
6100
|
+
}
|
|
6101
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.create", outcome: "success" });
|
|
6102
|
+
_redirect(res, "/admin/campaigns?saved=1");
|
|
6103
|
+
},
|
|
6104
|
+
));
|
|
6105
|
+
|
|
6106
|
+
// Detail — the campaign + its rendered (escape-by-default) preview, the
|
|
6107
|
+
// resolved reachable count (computed live), the send-ledger counts, and
|
|
6108
|
+
// the test-send + send actions. Content-negotiated.
|
|
6109
|
+
router.get("/admin/campaigns/:slug", _pageOrApi(true,
|
|
6110
|
+
R(async function (req, res) {
|
|
6111
|
+
var c = await emailCampaigns.getCampaign(req.params.slug);
|
|
6112
|
+
if (!c) return _problem(res, 404, "email-campaign-not-found");
|
|
6113
|
+
var reach = null;
|
|
6114
|
+
try { reach = await emailCampaigns.reachability(req.params.slug); }
|
|
6115
|
+
catch (_e) { reach = null; }
|
|
6116
|
+
var counts = await emailCampaigns.sendCounts(req.params.slug);
|
|
6117
|
+
_json(res, 200, Object.assign({}, c, { reachability: reach, send_counts: counts, can_broadcast: emailCampaigns.canBroadcast() }));
|
|
6118
|
+
}),
|
|
6119
|
+
async function (req, res) {
|
|
6120
|
+
var url = req.url ? new URL(req.url, "http://localhost") : null;
|
|
6121
|
+
var c = await emailCampaigns.getCampaign(req.params.slug);
|
|
6122
|
+
if (!c) return _sendHtml(res, 404, renderAdminCampaigns({
|
|
6123
|
+
shop_name: deps.shop_name, nav_available: navAvailable, rows: [],
|
|
6124
|
+
can_broadcast: emailCampaigns.canBroadcast(), notice: "Campaign not found.",
|
|
6125
|
+
}));
|
|
6126
|
+
var preview = null;
|
|
6127
|
+
try { preview = await emailCampaigns.previewCampaign(req.params.slug); }
|
|
6128
|
+
catch (_e) { preview = null; }
|
|
6129
|
+
var reach = null;
|
|
6130
|
+
try { reach = await emailCampaigns.reachability(req.params.slug); }
|
|
6131
|
+
catch (_e) { reach = null; }
|
|
6132
|
+
var counts = await emailCampaigns.sendCounts(req.params.slug);
|
|
6133
|
+
_sendHtml(res, 200, renderAdminCampaign({
|
|
6134
|
+
shop_name: deps.shop_name, nav_available: navAvailable,
|
|
6135
|
+
campaign: c, preview: preview, reachability: reach, counts: counts,
|
|
6136
|
+
can_broadcast: emailCampaigns.canBroadcast(),
|
|
6137
|
+
sent: url && url.searchParams.get("sent"),
|
|
6138
|
+
tested: url && url.searchParams.get("tested"),
|
|
6139
|
+
notice: (url && url.searchParams.get("err")) ? _campaignErrNotice(url.searchParams.get("err")) : null,
|
|
6140
|
+
}));
|
|
6141
|
+
},
|
|
6142
|
+
));
|
|
6143
|
+
|
|
6144
|
+
// Test-send — render + mail the campaign to ONE operator-supplied
|
|
6145
|
+
// address (bypasses the audience + consent gate; it's the operator's
|
|
6146
|
+
// own inbox). Rate-bound on the shared send window.
|
|
6147
|
+
router.post("/admin/campaigns/:slug/test", _pageOrApi(false,
|
|
6148
|
+
W("email_campaign.test", async function (req, res) {
|
|
6149
|
+
var slug = req.params.slug;
|
|
6150
|
+
var out;
|
|
6151
|
+
try { out = await emailCampaigns.testSend(slug, (req.body || {}).to); }
|
|
6152
|
+
catch (e) {
|
|
6153
|
+
if (e instanceof TypeError) {
|
|
6154
|
+
var code = e.code === "EMAIL_CAMPAIGN_RATE_LIMITED" ? 429 : 400;
|
|
6155
|
+
return _problem(res, code, e.code === "EMAIL_CAMPAIGN_RATE_LIMITED" ? "rate-limited" : "bad-request", e.message);
|
|
6156
|
+
}
|
|
6157
|
+
_safeNotice(e, "email_campaign.test");
|
|
6158
|
+
return _problem(res, 502, "send-failed", "The test message could not be sent.");
|
|
6159
|
+
}
|
|
6160
|
+
_json(res, 200, out);
|
|
6161
|
+
return { id: slug };
|
|
6162
|
+
}),
|
|
6163
|
+
async function (req, res) {
|
|
6164
|
+
var slug = req.params.slug;
|
|
6165
|
+
var enc = encodeURIComponent(slug);
|
|
6166
|
+
try { await emailCampaigns.testSend(slug, (req.body || {}).to); }
|
|
6167
|
+
catch (e) {
|
|
6168
|
+
if (e instanceof TypeError) {
|
|
6169
|
+
var reason = e.code === "EMAIL_CAMPAIGN_RATE_LIMITED" ? "rate" : "test";
|
|
6170
|
+
return _redirect(res, "/admin/campaigns/" + enc + "?err=" + reason);
|
|
6171
|
+
}
|
|
6172
|
+
_safeNotice(e, "email_campaign.test");
|
|
6173
|
+
return _redirect(res, "/admin/campaigns/" + enc + "?err=send");
|
|
6174
|
+
}
|
|
6175
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.test", outcome: "success", metadata: { slug: slug } });
|
|
6176
|
+
_redirect(res, "/admin/campaigns/" + enc + "?tested=1");
|
|
6177
|
+
},
|
|
6178
|
+
));
|
|
6179
|
+
|
|
6180
|
+
// Send — the consent-gated broadcast. Resolves reachability at the
|
|
6181
|
+
// send moment, drains the audience, only marketing-consented +
|
|
6182
|
+
// deliverable recipients receive, every message carries the one-click
|
|
6183
|
+
// unsubscribe pair. One bad address is counted, never fatal.
|
|
6184
|
+
router.post("/admin/campaigns/:slug/send", _pageOrApi(false,
|
|
6185
|
+
W("email_campaign.send", async function (req, res) {
|
|
6186
|
+
var slug = req.params.slug;
|
|
6187
|
+
var c = await emailCampaigns.getCampaign(slug);
|
|
6188
|
+
if (!c) return _problem(res, 404, "email-campaign-not-found");
|
|
6189
|
+
var out;
|
|
6190
|
+
try { out = await emailCampaigns.broadcast(slug); }
|
|
6191
|
+
catch (e) {
|
|
6192
|
+
if (e instanceof TypeError) {
|
|
6193
|
+
var code = e.code === "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE" ? 409 : 400;
|
|
6194
|
+
return _problem(res, code, e.code === "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE" ? "broadcast-unavailable" : "bad-request", e.message);
|
|
6195
|
+
}
|
|
6196
|
+
throw e;
|
|
6197
|
+
}
|
|
6198
|
+
_json(res, 200, out);
|
|
6199
|
+
return { id: slug };
|
|
6200
|
+
}),
|
|
6201
|
+
async function (req, res) {
|
|
6202
|
+
var slug = req.params.slug;
|
|
6203
|
+
var enc = encodeURIComponent(slug);
|
|
6204
|
+
var c = await emailCampaigns.getCampaign(slug);
|
|
6205
|
+
if (!c) return _redirect(res, "/admin/campaigns?err=notfound");
|
|
6206
|
+
try { await emailCampaigns.broadcast(slug); }
|
|
6207
|
+
catch (e) {
|
|
6208
|
+
if (e instanceof TypeError) {
|
|
6209
|
+
var reason = e.code === "EMAIL_CAMPAIGN_BROADCAST_UNAVAILABLE" ? "unavailable" : "send";
|
|
6210
|
+
return _redirect(res, "/admin/campaigns/" + enc + "?err=" + reason);
|
|
6211
|
+
}
|
|
6212
|
+
_safeNotice(e, "email_campaign.send");
|
|
6213
|
+
return _redirect(res, "/admin/campaigns/" + enc + "?err=send");
|
|
6214
|
+
}
|
|
6215
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.send", outcome: "success", metadata: { slug: slug } });
|
|
6216
|
+
_redirect(res, "/admin/campaigns/" + enc + "?sent=1");
|
|
6217
|
+
},
|
|
6218
|
+
));
|
|
6219
|
+
|
|
6220
|
+
// Cancel — terminal off-ramp for a draft / scheduled campaign.
|
|
6221
|
+
router.post("/admin/campaigns/:slug/cancel", _pageOrApi(false,
|
|
6222
|
+
W("email_campaign.cancel", async function (req, res) {
|
|
6223
|
+
var slug = req.params.slug;
|
|
6224
|
+
var reason = (req.body || {}).reason || "Cancelled from the console.";
|
|
6225
|
+
var out;
|
|
6226
|
+
try { out = await emailCampaigns.cancelCampaign(slug, reason); }
|
|
6227
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
6228
|
+
_json(res, 200, out);
|
|
6229
|
+
return { id: slug };
|
|
6230
|
+
}),
|
|
6231
|
+
async function (req, res) {
|
|
6232
|
+
var slug = req.params.slug;
|
|
6233
|
+
var enc = encodeURIComponent(slug);
|
|
6234
|
+
var reason = (req.body || {}).reason || "Cancelled from the console.";
|
|
6235
|
+
try { await emailCampaigns.cancelCampaign(slug, reason); }
|
|
6236
|
+
catch (e) { if (!(e instanceof TypeError)) throw e; return _redirect(res, "/admin/campaigns/" + enc + "?err=cancel"); }
|
|
6237
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".email_campaign.cancel", outcome: "success", metadata: { slug: slug } });
|
|
6238
|
+
_redirect(res, "/admin/campaigns?saved=1");
|
|
6239
|
+
},
|
|
6240
|
+
));
|
|
6241
|
+
}
|
|
6242
|
+
|
|
5706
6243
|
// ---- gift wraps -----------------------------------------------------
|
|
5707
6244
|
//
|
|
5708
6245
|
// The operator-defined gift-wrap catalog: define / update / archive a wrap
|
|
@@ -12195,6 +12732,16 @@ function mount(router, deps) {
|
|
|
12195
12732
|
|
|
12196
12733
|
router.post("/admin/setup", async function (req, res) {
|
|
12197
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
|
+
}
|
|
12198
12745
|
var body = req.body || {};
|
|
12199
12746
|
var values = {
|
|
12200
12747
|
shop_name: (typeof body.shop_name === "string" ? body.shop_name : "").trim(),
|
|
@@ -12237,6 +12784,241 @@ function mount(router, deps) {
|
|
|
12237
12784
|
});
|
|
12238
12785
|
}
|
|
12239
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
|
+
|
|
12240
13022
|
// ---- ping (auth check) ----------------------------------------------
|
|
12241
13023
|
|
|
12242
13024
|
router.get("/admin/ping", R(async function (_req, res) {
|
|
@@ -12598,6 +13380,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
12598
13380
|
{ key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
|
|
12599
13381
|
{ key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
|
|
12600
13382
|
{ key: "promo-banners", href: "/admin/promo-banners", label: "Promo banners", requires: "promoBanners" },
|
|
13383
|
+
{ key: "campaigns", href: "/admin/campaigns", label: "Email campaigns", requires: "emailCampaigns" },
|
|
12601
13384
|
{ key: "blog", href: "/admin/blog", label: "Blog", requires: "blog" },
|
|
12602
13385
|
{ key: "help", href: "/admin/help", label: "Help center", requires: "knowledgeBase" },
|
|
12603
13386
|
{ key: "pages", href: "/admin/pages", label: "Pages", requires: "storefrontPages" },
|
|
@@ -12606,6 +13389,7 @@ var ADMIN_NAV_ITEMS = [
|
|
|
12606
13389
|
{ key: "hours", href: "/admin/hours", label: "Hours", requires: "businessHours" },
|
|
12607
13390
|
{ key: "giftcards", href: "/admin/gift-cards", label: "Gift cards", requires: "giftcards" },
|
|
12608
13391
|
{ key: "webhooks", href: "/admin/webhooks", label: "Webhooks", requires: "webhooks" },
|
|
13392
|
+
{ key: "operators", href: "/admin/operators", label: "Operators", requires: "operators" },
|
|
12609
13393
|
{ key: "integrations", href: "/admin/integrations", label: "Integrations" },
|
|
12610
13394
|
{ key: "setup", href: "/admin/setup", label: "Setup" },
|
|
12611
13395
|
];
|
|
@@ -12671,6 +13455,26 @@ function renderAdminConfirm(opts) {
|
|
|
12671
13455
|
return _renderAdminShell(opts.shop_name, opts.heading || "Confirm", body, opts.active, opts.nav_available);
|
|
12672
13456
|
}
|
|
12673
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
|
+
|
|
12674
13478
|
function renderAdminLogin(opts) {
|
|
12675
13479
|
opts = opts || {};
|
|
12676
13480
|
var err = opts.error
|
|
@@ -12691,11 +13495,142 @@ function renderAdminLogin(opts) {
|
|
|
12691
13495
|
"</label>" +
|
|
12692
13496
|
"<button type=\"submit\" class=\"btn\">Sign in</button>" +
|
|
12693
13497
|
"</form>" +
|
|
13498
|
+
"<p class=\"signin-lede\"><a href=\"/admin/operators/signin\">Sign in as an operator →</a></p>" +
|
|
12694
13499
|
"</div>" +
|
|
12695
13500
|
"</div>";
|
|
12696
13501
|
return _renderAdminShell(shopName, "Sign in", body, null);
|
|
12697
13502
|
}
|
|
12698
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
|
+
|
|
12699
13634
|
function renderAdminLanding(opts) {
|
|
12700
13635
|
opts = opts || {};
|
|
12701
13636
|
var setupBanner = opts.setup_complete
|
|
@@ -15421,6 +16356,195 @@ function renderAdminInvReceive(opts) {
|
|
|
15421
16356
|
return _renderAdminShell(opts.shop_name, "Receive stock", body, "inventory-receive", opts.nav_available);
|
|
15422
16357
|
}
|
|
15423
16358
|
|
|
16359
|
+
// ---- email campaign console renders ----------------------------------
|
|
16360
|
+
|
|
16361
|
+
// Map an ?err= code on a campaign console redirect to an operator-facing
|
|
16362
|
+
// notice. The codes are emitted by the campaign routes, never operator
|
|
16363
|
+
// free text, so no escaping concern here.
|
|
16364
|
+
function _campaignErrNotice(code) {
|
|
16365
|
+
if (code === "bad") return "Check the campaign — slug, subject, body, audience, and a valid sender address are all required.";
|
|
16366
|
+
if (code === "rate") return "Send rate limit reached. Try again in a moment.";
|
|
16367
|
+
if (code === "test") return "The test recipient address wasn't valid.";
|
|
16368
|
+
if (code === "send") return "The send couldn't be completed. Check the error log.";
|
|
16369
|
+
if (code === "unavailable") return "Broadcast isn't available — this deployment has no deliverable-address source (newsletter list) or no configured unsubscribe origin.";
|
|
16370
|
+
if (code === "cancel") return "That campaign can't be cancelled from its current state.";
|
|
16371
|
+
if (code === "notfound") return "Campaign not found.";
|
|
16372
|
+
return "That action couldn't be completed.";
|
|
16373
|
+
}
|
|
16374
|
+
|
|
16375
|
+
function _campaignStatusPill(status) {
|
|
16376
|
+
var cls = "status-pill";
|
|
16377
|
+
if (status === "sent") cls = "status-pill status-pill--ok";
|
|
16378
|
+
if (status === "cancelled") cls = "status-pill status-pill--muted";
|
|
16379
|
+
if (status === "sending") cls = "status-pill status-pill--warn";
|
|
16380
|
+
return "<span class=\"" + cls + "\">" + _htmlEscape(String(status)) + "</span>";
|
|
16381
|
+
}
|
|
16382
|
+
|
|
16383
|
+
// Per-recipient delivery counts cell — "12 sent · 3 skipped · 1 failed".
|
|
16384
|
+
// All four outcomes roll into a compact summary; a zero-everything
|
|
16385
|
+
// campaign shows an em-dash.
|
|
16386
|
+
function _campaignCountsSummary(counts) {
|
|
16387
|
+
if (!counts) return "<span class=\"meta\">—</span>";
|
|
16388
|
+
var sent = Number(counts.sent || 0);
|
|
16389
|
+
var skipped = Number(counts.skipped_unsubscribed || 0) + Number(counts.skipped_suppressed || 0);
|
|
16390
|
+
var failed = Number(counts.failed || 0);
|
|
16391
|
+
if (!sent && !skipped && !failed) return "<span class=\"meta\">—</span>";
|
|
16392
|
+
var parts = [];
|
|
16393
|
+
parts.push(_htmlEscape(String(sent)) + " sent");
|
|
16394
|
+
if (skipped) parts.push(_htmlEscape(String(skipped)) + " skipped");
|
|
16395
|
+
if (failed) parts.push(_htmlEscape(String(failed)) + " failed");
|
|
16396
|
+
return _htmlEscape(parts.join(" · "));
|
|
16397
|
+
}
|
|
16398
|
+
|
|
16399
|
+
function renderAdminCampaigns(opts) {
|
|
16400
|
+
opts = opts || {};
|
|
16401
|
+
var rows = opts.rows || [];
|
|
16402
|
+
var saved = opts.saved ? "<div class=\"banner banner--ok\">Campaign saved.</div>" : "";
|
|
16403
|
+
var sent = opts.sent ? "<div class=\"banner banner--ok\">Campaign sent.</div>" : "";
|
|
16404
|
+
var tested = opts.tested ? "<div class=\"banner banner--ok\">Test message sent.</div>" : "";
|
|
16405
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
16406
|
+
|
|
16407
|
+
// Honesty banner — when the broadcast path isn't wired (no deliverable
|
|
16408
|
+
// address source / no unsubscribe origin), say so plainly. Campaigns
|
|
16409
|
+
// can still be authored + previewed; the Send action will refuse.
|
|
16410
|
+
var reachBanner = opts.can_broadcast
|
|
16411
|
+
? ""
|
|
16412
|
+
: "<div class=\"banner banner--warn\">Sending is unavailable: this store has no deliverable-address source " +
|
|
16413
|
+
"(a newsletter subscriber list with plaintext addresses) or no configured unsubscribe origin. " +
|
|
16414
|
+
"Customer email is stored hash-only, so only newsletter subscribers are reachable. You can still draft and preview campaigns.</div>";
|
|
16415
|
+
|
|
16416
|
+
var tableRows = rows.map(function (rc) {
|
|
16417
|
+
var c = rc.campaign;
|
|
16418
|
+
var enc = encodeURIComponent(c.slug);
|
|
16419
|
+
return "<tr>" +
|
|
16420
|
+
"<td><a href=\"/admin/campaigns/" + enc + "\">" + _htmlEscape(c.subject) + "</a><div class=\"meta\"><code>" + _htmlEscape(c.slug) + "</code></div></td>" +
|
|
16421
|
+
"<td>" + _htmlEscape(c.audience_slug) + "</td>" +
|
|
16422
|
+
"<td>" + _campaignStatusPill(c.status) + "</td>" +
|
|
16423
|
+
"<td>" + _campaignCountsSummary(rc.counts) + "</td>" +
|
|
16424
|
+
"</tr>";
|
|
16425
|
+
}).join("");
|
|
16426
|
+
|
|
16427
|
+
var list = rows.length
|
|
16428
|
+
? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Subject</th><th scope=\"col\">Audience</th><th scope=\"col\">Status</th><th scope=\"col\">Delivery</th></tr></thead><tbody>" + tableRows + "</tbody></table>") + "</div>"
|
|
16429
|
+
: "<p class=\"empty\">No campaigns yet. Create one to broadcast to a mailing audience.</p>";
|
|
16430
|
+
|
|
16431
|
+
var body =
|
|
16432
|
+
"<section><h2>Email campaigns</h2>" +
|
|
16433
|
+
"<p class=\"meta\">Broadcast a message to a mailing audience. Only marketing-consented, reachable subscribers receive a campaign — consent is checked at send time, and every message carries a one-click unsubscribe.</p>" +
|
|
16434
|
+
saved + sent + tested + notice + reachBanner +
|
|
16435
|
+
"<div class=\"actions-row\"><a class=\"btn\" href=\"/admin/campaigns/new\">New campaign</a></div>" +
|
|
16436
|
+
list +
|
|
16437
|
+
"</section>";
|
|
16438
|
+
return _renderAdminShell(opts.shop_name, "Email campaigns", body, "campaigns", opts.nav_available);
|
|
16439
|
+
}
|
|
16440
|
+
|
|
16441
|
+
function renderAdminCampaignNew(opts) {
|
|
16442
|
+
opts = opts || {};
|
|
16443
|
+
var audiences = opts.audiences || [];
|
|
16444
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
16445
|
+
|
|
16446
|
+
var audOptions = audiences.map(function (a) {
|
|
16447
|
+
return "<option value=\"" + _htmlEscape(a.slug) + "\">" + _htmlEscape(a.title) + " (" + _htmlEscape(a.slug) + ")</option>";
|
|
16448
|
+
}).join("");
|
|
16449
|
+
var audField = audiences.length
|
|
16450
|
+
? "<label class=\"form-field\"><span>Audience</span><select name=\"audience_slug\" required>" + audOptions + "</select>" +
|
|
16451
|
+
"<small>The mailing audience to broadcast to. Manage audiences from the mailing-audiences API.</small></label>"
|
|
16452
|
+
: "<label class=\"form-field\"><span>Audience slug</span><input type=\"text\" name=\"audience_slug\" maxlength=\"64\" required>" +
|
|
16453
|
+
"<small>No mailing audiences are defined yet — enter the slug of one you've created via the API.</small></label>";
|
|
16454
|
+
|
|
16455
|
+
var body =
|
|
16456
|
+
"<section class=\"mw-42\"><h2>New campaign</h2>" + notice +
|
|
16457
|
+
"<form method=\"post\" action=\"/admin/campaigns\">" +
|
|
16458
|
+
_setupField("Campaign slug", "slug", "", "text", "Lowercase id, e.g. spring-sale-2026.", " maxlength=\"64\" required") +
|
|
16459
|
+
_setupField("Subject", "subject", "", "text", "The email subject line.", " maxlength=\"200\" required") +
|
|
16460
|
+
"<label class=\"form-field\"><span>Body (Markdown)</span>" +
|
|
16461
|
+
"<textarea name=\"body_html\" rows=\"10\" maxlength=\"100000\" required></textarea>" +
|
|
16462
|
+
"<small>Markdown — headings, lists, links, bold/italic. Rendered escape-by-default: raw HTML is shown as text, links must be https.</small></label>" +
|
|
16463
|
+
audField +
|
|
16464
|
+
_setupField("From address", "from_address", "", "email", "The sender address (must be a domain you can send from).", " maxlength=\"254\" required") +
|
|
16465
|
+
_setupField("From name", "from_name", "", "text", "The friendly sender name.", " maxlength=\"100\" required") +
|
|
16466
|
+
_setupField("Reply-to", "reply_to", "", "email", "Optional — where replies land.", " maxlength=\"254\"") +
|
|
16467
|
+
"<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Create campaign</button>" +
|
|
16468
|
+
"<a class=\"btn btn--ghost\" href=\"/admin/campaigns\">Cancel</a></div>" +
|
|
16469
|
+
"</form>" +
|
|
16470
|
+
"</section>";
|
|
16471
|
+
return _renderAdminShell(opts.shop_name, "New campaign", body, "campaigns", opts.nav_available);
|
|
16472
|
+
}
|
|
16473
|
+
|
|
16474
|
+
function renderAdminCampaign(opts) {
|
|
16475
|
+
opts = opts || {};
|
|
16476
|
+
var c = opts.campaign;
|
|
16477
|
+
var enc = encodeURIComponent(c.slug);
|
|
16478
|
+
var sent = opts.sent ? "<div class=\"banner banner--ok\">Campaign sent.</div>" : "";
|
|
16479
|
+
var tested = opts.tested ? "<div class=\"banner banner--ok\">Test message sent.</div>" : "";
|
|
16480
|
+
var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
|
|
16481
|
+
|
|
16482
|
+
// Resolved reachable count — the true send size, computed live. The
|
|
16483
|
+
// operator sees this BEFORE confirming a send.
|
|
16484
|
+
var reach = opts.reachability;
|
|
16485
|
+
var reachPanel = reach
|
|
16486
|
+
? "<div class=\"panel\"><h3 class=\"subhead\">Reachable recipients</h3>" +
|
|
16487
|
+
"<p class=\"big-stat\">" + _htmlEscape(String(reach.reachable)) + "</p>" +
|
|
16488
|
+
"<p class=\"meta\">Resolved at send time from " + _htmlEscape(String(reach.resolved)) + " audience members: " +
|
|
16489
|
+
_htmlEscape(String(reach.reachable)) + " reachable, " +
|
|
16490
|
+
_htmlEscape(String(reach.suppressed)) + " suppressed, " +
|
|
16491
|
+
_htmlEscape(String(reach.unsubscribed)) + " unsubscribed, " +
|
|
16492
|
+
_htmlEscape(String(reach.no_address)) + " with no deliverable address.</p>" +
|
|
16493
|
+
"</div>"
|
|
16494
|
+
: "<div class=\"panel\"><p class=\"meta\">Reachability can't be resolved — no deliverable-address source is wired.</p></div>";
|
|
16495
|
+
|
|
16496
|
+
// Per-recipient delivery counts from the send ledger.
|
|
16497
|
+
var counts = opts.counts || {};
|
|
16498
|
+
var countsPanel = "<div class=\"panel\"><h3 class=\"subhead\">Delivery</h3>" +
|
|
16499
|
+
"<ul class=\"kv-list\">" +
|
|
16500
|
+
"<li><span>Sent</span><strong>" + _htmlEscape(String(counts.sent || 0)) + "</strong></li>" +
|
|
16501
|
+
"<li><span>Skipped (unsubscribed)</span><strong>" + _htmlEscape(String(counts.skipped_unsubscribed || 0)) + "</strong></li>" +
|
|
16502
|
+
"<li><span>Skipped (suppressed)</span><strong>" + _htmlEscape(String(counts.skipped_suppressed || 0)) + "</strong></li>" +
|
|
16503
|
+
"<li><span>Failed</span><strong>" + _htmlEscape(String(counts.failed || 0)) + "</strong></li>" +
|
|
16504
|
+
"</ul></div>";
|
|
16505
|
+
|
|
16506
|
+
// Rendered preview — the renderer already escaped the operator body, so
|
|
16507
|
+
// the preview HTML is splice-safe (no double-escape). Spliced literally
|
|
16508
|
+
// so a `$` in the body can't trip String.replace dollar substitution.
|
|
16509
|
+
var previewBody = opts.preview ? opts.preview.body_html : "<span class=\"meta\">Preview unavailable.</span>";
|
|
16510
|
+
var previewPanel = "<div class=\"panel\"><h3 class=\"subhead\">Preview</h3>" +
|
|
16511
|
+
"<div class=\"mail-preview\">RAW_PREVIEW_BODY</div></div>";
|
|
16512
|
+
|
|
16513
|
+
// Send / test actions only when the campaign isn't terminal.
|
|
16514
|
+
var isTerminal = c.status === "sent" || c.status === "cancelled";
|
|
16515
|
+
var actions = "";
|
|
16516
|
+
if (!isTerminal) {
|
|
16517
|
+
var sendBtn = opts.can_broadcast
|
|
16518
|
+
? "<form method=\"post\" action=\"/admin/campaigns/" + enc + "/send\" class=\"form-inline\">" +
|
|
16519
|
+
"<button class=\"btn\" type=\"submit\">Send to " + _htmlEscape(reach ? String(reach.reachable) : "0") + " recipient(s)</button></form>"
|
|
16520
|
+
: "<span class=\"meta\">Sending is unavailable on this deployment.</span>";
|
|
16521
|
+
actions =
|
|
16522
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Send a test</h3>" +
|
|
16523
|
+
"<form method=\"post\" action=\"/admin/campaigns/" + enc + "/test\" class=\"form-inline\">" +
|
|
16524
|
+
"<input type=\"email\" name=\"to\" placeholder=\"you@example.com\" maxlength=\"254\" required>" +
|
|
16525
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Send test</button></form></div>" +
|
|
16526
|
+
"<div class=\"panel\"><h3 class=\"subhead\">Send campaign</h3>" + sendBtn +
|
|
16527
|
+
"<form method=\"post\" action=\"/admin/campaigns/" + enc + "/cancel\" class=\"form-inline mt\">" +
|
|
16528
|
+
"<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Cancel campaign</button></form></div>";
|
|
16529
|
+
}
|
|
16530
|
+
|
|
16531
|
+
var meta = "<p class=\"meta\">Audience <code>" + _htmlEscape(c.audience_slug) + "</code> · " +
|
|
16532
|
+
"from " + _htmlEscape(c.from_name) + " <" + _htmlEscape(c.from_address) + "> · " +
|
|
16533
|
+
_campaignStatusPill(c.status) + "</p>";
|
|
16534
|
+
|
|
16535
|
+
var body =
|
|
16536
|
+
"<section><h2>" + _htmlEscape(c.subject) + "</h2>" + meta +
|
|
16537
|
+
sent + tested + notice +
|
|
16538
|
+
reachPanel + countsPanel + previewPanel + actions +
|
|
16539
|
+
"<div class=\"actions-row mt\"><a class=\"btn btn--ghost\" href=\"/admin/campaigns\">← All campaigns</a></div>" +
|
|
16540
|
+
"</section>";
|
|
16541
|
+
var html = _renderAdminShell(opts.shop_name, c.subject, body, "campaigns", opts.nav_available);
|
|
16542
|
+
// Splice the already-escaped preview body literally (it contains only the
|
|
16543
|
+
// fixed tag set the escape-by-default renderer emits; the operator's
|
|
16544
|
+
// bytes were escaped before this point).
|
|
16545
|
+
return _spliceRaw(html, "RAW_PREVIEW_BODY", previewBody);
|
|
16546
|
+
}
|
|
16547
|
+
|
|
15424
16548
|
// Location→location transfer console: the open form + the open-transfer
|
|
15425
16549
|
// queue with the FSM action legal from each row's status. Reasons,
|
|
15426
16550
|
// carriers, and tracking numbers are operator free text — escaped.
|
|
@@ -20478,4 +21602,6 @@ module.exports = {
|
|
|
20478
21602
|
renderAdminPickupLocations: renderAdminPickupLocations,
|
|
20479
21603
|
renderAdminPickups: renderAdminPickups,
|
|
20480
21604
|
renderAdminGiftWraps: renderAdminGiftWraps,
|
|
21605
|
+
renderOperators: renderOperators,
|
|
21606
|
+
renderOperatorSignin: renderOperatorSignin,
|
|
20481
21607
|
};
|