@redeemid/emdash 0.0.30 → 0.0.31
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/README.md +2 -2
- package/docs/access.md +7 -5
- package/docs/content.md +1 -1
- package/docs/developer-api.md +1 -1
- package/docs/getting-started.md +4 -4
- package/docs/production.md +3 -3
- package/docs/reference.md +3 -3
- package/docs/shopify-flow.md +2 -2
- package/docs/shopify-setup.md +2 -2
- package/docs/staff.md +18 -8
- package/package.json +11 -2
- package/src/access-admin.ts +211 -76
- package/src/access-map.ts +143 -0
- package/src/admin.ts +505 -0
- package/src/descriptor.ts +13 -9
- package/src/monitor.ts +2 -2
- package/src/plugin.ts +302 -76
- package/src/staff.ts +148 -66
- package/src/version.ts +1 -1
package/src/plugin.ts
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* })
|
|
8
8
|
*
|
|
9
9
|
* It adds Portable Text blocks to the editor, an Access panel on every entry,
|
|
10
|
-
* admin pages (Members, Access,
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* admin pages (Overview, Members, Access, Reports, Privacy, Webhooks, App
|
|
11
|
+
* events, Status, Setup), a daily webhook health check and a dashboard
|
|
12
|
+
* widget. The pages are Block Kit built here; admin.ts renders them in the
|
|
13
|
+
* browser. Sign-in, webhooks and checkout stay on the auth provider's routes.
|
|
13
14
|
*
|
|
14
15
|
* - "Paywall break" (`redeemid-paywall`): marks where the entry's rule starts.
|
|
15
16
|
* Everything before it is for everyone; everything after it follows the
|
|
@@ -33,17 +34,17 @@ import { deleteAccess, entryKey, loadAccess, onLevels, withSaved } from "./setti
|
|
|
33
34
|
import { env, ENV, shopOrigin, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
|
|
34
35
|
import { discover } from "./shopify.js";
|
|
35
36
|
import { stores, type MemberRecord, type Stores } from "./storage.js";
|
|
36
|
-
import { EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
|
|
37
|
+
import { CONNECTION_TABS, EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
|
|
37
38
|
import { VERSION } from "./version.js";
|
|
38
39
|
import { AdminError, adminConfigured } from "./admin-api.js";
|
|
39
|
-
import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
|
|
40
|
-
import {
|
|
40
|
+
import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, recentRequests, requestShopifyErasure } from "./privacy.js";
|
|
41
|
+
import { bulkResync, BULK_BATCH, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
|
|
41
42
|
import { textProblems } from "./text.js";
|
|
42
43
|
import { cacheFindings, gatedFindings, templateFindings } from "./template-scan.js";
|
|
43
|
-
import { setupFacts, setupPage, WEBHOOK } from "./setup.js";
|
|
44
|
+
import { setupFacts, setupPage, setupSteps, WEBHOOK } from "./setup.js";
|
|
44
45
|
import { record } from "./log.js";
|
|
45
46
|
import { resend } from "./events.js";
|
|
46
|
-
import { eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
|
|
47
|
+
import { eventRow, eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, WEBHOOK_TABS, webhooksPage, type MonitorEnv, type WebhookTab } from "./monitor.js";
|
|
47
48
|
import { z } from "astro/zod";
|
|
48
49
|
import { AgentError, explainAccess, findProducts, getCollection, getMember, getProduct, getRules, levelChanges, listLevels, siteHealth, type ExplainInput } from "./agents.js";
|
|
49
50
|
|
|
@@ -212,6 +213,9 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
212
213
|
// rule follows the entry's slug and goes with the entry.
|
|
213
214
|
// email:send: the daily webhook check can warn staff by email.
|
|
214
215
|
capabilities: ["content:read", "email:send"],
|
|
216
|
+
// No admin.entry here: EmDash turns off Block Kit editor panels for a
|
|
217
|
+
// plugin with one. The descriptor's adminEntry alone loads admin.ts,
|
|
218
|
+
// which renders the pages; the Access panel stays Block Kit.
|
|
215
219
|
admin: {
|
|
216
220
|
pages: PAGES,
|
|
217
221
|
widgets: WIDGETS,
|
|
@@ -281,17 +285,16 @@ export function createPlugin(code: RedeemIDConfig = {}): ResolvedPlugin {
|
|
|
281
285
|
if ((i.type === "block_action" || i.type === "form_submit") && isSetupAction(i.action_id)) return await setupAction(config, live, ctx, i);
|
|
282
286
|
if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx, i);
|
|
283
287
|
if (i.type !== "page_load") return { blocks: [] };
|
|
284
|
-
if (i.page === "/") return await
|
|
288
|
+
if (i.page === "/") return await overviewPage(config, ctx);
|
|
289
|
+
if (i.page === "/members") return await membersPage(config);
|
|
285
290
|
if (i.page === "/access") return await accessPage(await accessEnv(config));
|
|
286
|
-
if (i.page === "/
|
|
291
|
+
if (i.page === "/reports") return await reportsPage(config, await memberStores());
|
|
292
|
+
// Connection, or one of its tabs by address (/connection/webhooks).
|
|
293
|
+
if (i.page === "/connection" || i.page?.startsWith("/connection/")) {
|
|
287
294
|
await scheduleHealth(ctx);
|
|
288
|
-
|
|
295
|
+
const tab = i.page.split("/")[2] as ConnectionTab | undefined;
|
|
296
|
+
return await connectionPage(config, ctx, tab && CONNECTION_TABS.includes(tab) ? tab : undefined);
|
|
289
297
|
}
|
|
290
|
-
if (i.page === "/events") return await eventsPage(await memberStores());
|
|
291
|
-
if (i.page === "/reports") return await reportsPage(config, await memberStores());
|
|
292
|
-
if (i.page === "/setup") return setupPage(await setupFacts(config, await memberStores(), siteOrigin(ctx)));
|
|
293
|
-
if (i.page === "/status") return await statusPage(config, ctx.request);
|
|
294
|
-
if (i.page === "/test") return await accessTest(config);
|
|
295
298
|
if (i.page === "/privacy") return await privacyPage(await memberStores());
|
|
296
299
|
if (i.page === "widget:status") return await statusWidget(config, ctx);
|
|
297
300
|
} catch (e) {
|
|
@@ -340,6 +343,12 @@ async function accessEnv(config: RedeemIDConfig): Promise<AccessEnv> {
|
|
|
340
343
|
saved: await loadAccess(store),
|
|
341
344
|
collections: collections.map((c) => ({ slug: c.slug, label: c.label || c.slug })),
|
|
342
345
|
taxonomies: taxonomies.map((t) => ({ name: t.name, label: t.label || t.name, collections: t.collections ?? [] })),
|
|
346
|
+
holders: async () => {
|
|
347
|
+
const levels = config.levels ?? [];
|
|
348
|
+
const out: Record<string, number> = Object.fromEntries(levels.map((l) => [l.slug, 0]));
|
|
349
|
+
for (const m of await allMembers()) if (m.status === "enabled") for (const slug of levelsFor(m.tags, levels)) out[slug] = (out[slug] ?? 0) + 1;
|
|
350
|
+
return out;
|
|
351
|
+
},
|
|
343
352
|
terms: async (taxonomy) => {
|
|
344
353
|
type Node = { slug: string; label?: string; name?: string; children?: Node[] };
|
|
345
354
|
const out: Array<{ slug: string; label: string }> = [];
|
|
@@ -474,12 +483,15 @@ function siteOrigin(ctx: PluginContext): string {
|
|
|
474
483
|
async function setupAction(
|
|
475
484
|
config: RedeemIDConfig,
|
|
476
485
|
live: () => Promise<RedeemIDConfig>,
|
|
477
|
-
ctx:
|
|
486
|
+
ctx: RouteContext,
|
|
478
487
|
i: { action_id?: string; values?: Record<string, unknown> },
|
|
479
488
|
) {
|
|
480
489
|
const s = await memberStores();
|
|
481
490
|
const origin = siteOrigin(ctx);
|
|
482
|
-
const page = async (toast?: { type: "success" | "error" | "info"; message: string }) =>
|
|
491
|
+
const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => {
|
|
492
|
+
const now = await live();
|
|
493
|
+
return connectionPage(now, ctx, "setup", { setup: setupPage(await setupFacts(now, s, origin), toast) });
|
|
494
|
+
};
|
|
483
495
|
if (i.action_id === "setup_register") {
|
|
484
496
|
try {
|
|
485
497
|
const { added, failed } = await registerWebhooks(config, origin + WEBHOOK);
|
|
@@ -539,10 +551,10 @@ async function action(
|
|
|
539
551
|
const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
|
|
540
552
|
const adapter = createKyselyAdapter((await getDb()) as never);
|
|
541
553
|
const m = i.action_id === "member_link" ? await staffLink(adapter, s, id, i.values?.email) : await staffUnlink(adapter, s, id);
|
|
542
|
-
return memberDetail(config, m, { type: "success", message: m.userId ? "Linked. Their next sign-in uses that site user." : "Unlinked." });
|
|
554
|
+
return memberDetail(config, m, { type: "success", message: m.userId ? "Linked. Their next sign-in uses that site user." : "Unlinked." }, "account");
|
|
543
555
|
} catch (e) {
|
|
544
556
|
const m = await s.members.get(id);
|
|
545
|
-
return m ? memberDetail(config, m, fail(e)) : membersPage(config);
|
|
557
|
+
return m ? memberDetail(config, m, fail(e), "account") : membersPage(config);
|
|
546
558
|
}
|
|
547
559
|
}
|
|
548
560
|
case "member_tag_add":
|
|
@@ -553,10 +565,10 @@ async function action(
|
|
|
553
565
|
try {
|
|
554
566
|
const m = await tagEdit(config, s, id, add ? "add" : "remove", add ? i.values?.tags : [v.tag]);
|
|
555
567
|
const now = m ?? (await s.members.get(id));
|
|
556
|
-
return now ? memberDetail(config, now, { type: "success", message: add ? "Tags added in Shopify." : "Tag removed in Shopify." }) : membersPage(config);
|
|
568
|
+
return now ? memberDetail(config, now, { type: "success", message: add ? "Tags added in Shopify." : "Tag removed in Shopify." }, "tags") : membersPage(config);
|
|
557
569
|
} catch (e) {
|
|
558
570
|
const m = await s.members.get(id);
|
|
559
|
-
return m ? memberDetail(config, m, fail(e)) : membersPage(config);
|
|
571
|
+
return m ? memberDetail(config, m, fail(e), "tags") : membersPage(config);
|
|
560
572
|
}
|
|
561
573
|
}
|
|
562
574
|
case "members_resync": {
|
|
@@ -576,49 +588,47 @@ async function action(
|
|
|
576
588
|
const done = added.length ? `Registered ${added.length} webhooks.` : failed.length ? "" : "Nothing was missing.";
|
|
577
589
|
const refused = failed.map((f) => `Shopify refused ${f.topic}: ${f.message}`).join(" ");
|
|
578
590
|
await record({ event: "staff.webhooks", label: "register", status: failed.length ? `${failed.length} refused` : `${added.length} registered`, level: failed.length ? "warn" : "ok", origin: "staff" });
|
|
579
|
-
return webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
|
|
591
|
+
return connectionPage(config, ctx, "status", { hooks: await webhooksPage(monitor(), "setup", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") }) });
|
|
580
592
|
} catch (e) {
|
|
581
|
-
return webhooksPage(monitor(), "setup", fail(e));
|
|
593
|
+
return connectionPage(config, ctx, "status", { hooks: await webhooksPage(monitor(), "setup", fail(e)) });
|
|
582
594
|
}
|
|
583
595
|
}
|
|
584
596
|
case "webhooks_tab": {
|
|
585
597
|
const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
|
|
586
|
-
return
|
|
598
|
+
return connectionPage(config, ctx, tab === "setup" ? "status" : tab === "health" ? "health" : "webhooks");
|
|
587
599
|
}
|
|
588
600
|
case "deliveries_page":
|
|
589
|
-
return webhooksPage(monitor(), "received", undefined, cursorOf(i.value));
|
|
601
|
+
return connectionPage(config, ctx, "webhooks", { hooks: await webhooksPage(monitor(), "received", undefined, cursorOf(i.value)) });
|
|
590
602
|
case "health_check": {
|
|
591
603
|
const r = await healthCheck({ ...monitor(), by: "staff" });
|
|
592
604
|
const message = r.problems.length ? `Checked: ${r.problems.length === 1 ? "1 problem" : `${r.problems.length} problems`}.` : "Checked: subscriptions are in place and deliveries look healthy.";
|
|
593
|
-
return webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message });
|
|
605
|
+
return connectionPage(config, ctx, "health", { hooks: await webhooksPage(monitor(), "health", { type: r.problems.length ? "info" : "success", message }) });
|
|
594
606
|
}
|
|
595
607
|
case "monitor_save":
|
|
596
|
-
return webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values));
|
|
608
|
+
return connectionPage(config, ctx, "health", { hooks: await webhooksPage(monitor(), "health", await saveMonitor(ctx.kv, i.values)) });
|
|
597
609
|
case "events_filter":
|
|
598
|
-
return eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true });
|
|
610
|
+
return connectionPage(config, ctx, "events", { events: await eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true }) });
|
|
599
611
|
case "events_show":
|
|
600
|
-
return eventsPage(s, { event: String(i.value ?? "") || undefined });
|
|
612
|
+
return connectionPage(config, ctx, "events", { events: await eventsPage(s, { event: String(i.value ?? "") || undefined }) });
|
|
601
613
|
case "events_page":
|
|
602
|
-
return eventsPage(s, parseFilter(i.block_id), cursorOf(i.value));
|
|
614
|
+
return connectionPage(config, ctx, "events", { events: await eventsPage(s, parseFilter(i.block_id), cursorOf(i.value)) });
|
|
603
615
|
case "failed_resend": {
|
|
604
616
|
const sent = await resend(config, s, String(i.value ?? "")).catch(() => false);
|
|
605
|
-
return eventsPage(s, {}, undefined, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." });
|
|
617
|
+
return connectionPage(config, ctx, "events", { events: await eventsPage(s, {}, undefined, sent ? { type: "success", message: "Sent. It's off the failed list." } : { type: "error", message: "The receiver still didn't take it. It stays on the list." }) });
|
|
606
618
|
}
|
|
607
619
|
case "failed_clear":
|
|
608
620
|
await s.failedEvents.delete(String(i.value ?? ""));
|
|
609
|
-
return eventsPage(s, {}, undefined, { type: "success", message: "Cleared." });
|
|
621
|
+
return connectionPage(config, ctx, "events", { events: await eventsPage(s, {}, undefined, { type: "success", message: "Cleared." }) });
|
|
610
622
|
case "failed_clear_all": {
|
|
611
623
|
const { items } = await s.failedEvents.query({ limit: 100 });
|
|
612
624
|
await s.failedEvents.deleteMany(items.map((r) => r.id));
|
|
613
|
-
return eventsPage(s, {}, undefined, { type: "success", message: "Failed events cleared." });
|
|
625
|
+
return connectionPage(config, ctx, "events", { events: await eventsPage(s, {}, undefined, { type: "success", message: "Failed events cleared." }) });
|
|
614
626
|
}
|
|
615
|
-
case "access_test":
|
|
616
|
-
return accessTest(config, i.values);
|
|
617
627
|
case "member_export": {
|
|
618
628
|
const id = String(i.value ?? "");
|
|
619
629
|
const m = await s.members.get(id);
|
|
620
630
|
if (!m) return membersPage(config);
|
|
621
|
-
return
|
|
631
|
+
return memberDetail(config, m, { type: "success", message: "Export below." }, "privacy", await exportRequest(s, id, "staff"));
|
|
622
632
|
}
|
|
623
633
|
case "member_erase": {
|
|
624
634
|
const id = String(i.value ?? "");
|
|
@@ -633,9 +643,9 @@ async function action(
|
|
|
633
643
|
if (i.action_id === "member_shopify_erase") await requestShopifyErasure(config, s, id);
|
|
634
644
|
else await cancelShopifyErasure(config, s, id);
|
|
635
645
|
const message = i.action_id === "member_shopify_erase" ? "Shopify will erase the customer after its waiting period." : "The Shopify erasure is cancelled.";
|
|
636
|
-
return m ? memberDetail(config, m, { type: "success", message }) : privacyPage(s, { type: "success", message });
|
|
646
|
+
return m ? memberDetail(config, m, { type: "success", message }, "privacy") : privacyPage(s, { type: "success", message });
|
|
637
647
|
} catch (e) {
|
|
638
|
-
return m ? memberDetail(config, m, fail(e)) : privacyPage(s, fail(e));
|
|
648
|
+
return m ? memberDetail(config, m, fail(e), "privacy") : privacyPage(s, fail(e));
|
|
639
649
|
}
|
|
640
650
|
}
|
|
641
651
|
case "privacy_done": {
|
|
@@ -779,30 +789,94 @@ function textSummary(config: RedeemIDConfig): string {
|
|
|
779
789
|
return ignored ? `${all - ignored} in use, ${ignored} ignored` : `${all} in use`;
|
|
780
790
|
}
|
|
781
791
|
|
|
792
|
+
type ConnectionTab = (typeof CONNECTION_TABS)[number];
|
|
793
|
+
|
|
794
|
+
type Built = { blocks: unknown[]; toast?: unknown };
|
|
795
|
+
const withoutHeader = (b: Built) => b.blocks.filter((x, n) => !(n === 0 && (x as { type?: string }).type === "header"));
|
|
796
|
+
const panelsOf = (b: Built) => ((b.blocks.find((x) => (x as { type?: string }).type === "tab") as { panels?: Array<{ blocks: unknown[] }> } | undefined)?.panels ?? []).map((p) => p.blocks);
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Everything about the store connection on one page: the values Shopify
|
|
800
|
+
* needs (Status), the setup steps, webhook deliveries and their health, and
|
|
801
|
+
* the app's event log. An action passes the part it rebuilt (with its
|
|
802
|
+
* toast); the other tabs load as usual. Opens on Setup until it's done.
|
|
803
|
+
*/
|
|
804
|
+
async function connectionPage(config: RedeemIDConfig, ctx: RouteContext, tab?: ConnectionTab, given: { hooks?: Built; events?: Built; setup?: Built } = {}) {
|
|
805
|
+
const s = await memberStores();
|
|
806
|
+
const [hooks, events, facts, status] = await Promise.all([
|
|
807
|
+
given.hooks ?? webhooksPage(monitorEnv(config, s, ctx)),
|
|
808
|
+
given.events ?? eventsPage(s),
|
|
809
|
+
setupFacts(config, s, siteOrigin(ctx)),
|
|
810
|
+
statusPage(config, ctx.request),
|
|
811
|
+
]);
|
|
812
|
+
const setup = given.setup ?? setupPage(facts);
|
|
813
|
+
const steps = setupSteps(facts).filter((x) => x.state !== "optional" && x.key !== "storefront");
|
|
814
|
+
const ready = steps.every((x) => x.state === "done");
|
|
815
|
+
const [received = [], health = [], subscriptions = []] = panelsOf(hooks);
|
|
816
|
+
const attention = JSON.stringify(hooks.blocks).includes('"label":"Health (!)"');
|
|
817
|
+
const open = tab ?? (ready ? "status" : "setup");
|
|
818
|
+
const toast = (given.hooks ?? given.events ?? given.setup)?.toast;
|
|
819
|
+
return {
|
|
820
|
+
blocks: [
|
|
821
|
+
{ type: "header", text: "Connection" },
|
|
822
|
+
{ type: "context", text: "How this site talks to Shopify: the values to copy into Shopify, the setup steps, webhook deliveries and what RedeemID has done." },
|
|
823
|
+
{
|
|
824
|
+
type: "tab",
|
|
825
|
+
default_tab: CONNECTION_TABS.indexOf(open),
|
|
826
|
+
panels: [
|
|
827
|
+
{ label: "Status", blocks: [...withoutHeader(status), { type: "divider" }, { type: "header", text: "Webhook subscriptions" }, ...subscriptions] },
|
|
828
|
+
{ label: ready ? "Setup" : `Setup (${steps.filter((x) => x.state === "done").length}/${steps.length})`, blocks: withoutHeader(setup) },
|
|
829
|
+
{ label: "Webhooks", blocks: received },
|
|
830
|
+
{ label: attention ? "Health (!)" : "Health", blocks: health },
|
|
831
|
+
{ label: "App events", blocks: withoutHeader(events) },
|
|
832
|
+
],
|
|
833
|
+
},
|
|
834
|
+
],
|
|
835
|
+
...(toast ? { toast } : {}),
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
782
839
|
async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
783
840
|
const c = await checks(config);
|
|
784
841
|
const origin = new URL(request.url).origin;
|
|
785
|
-
const
|
|
842
|
+
const set = (v: boolean) => (v ? "Set" : "Missing");
|
|
786
843
|
const delivery = await lastDelivery().catch(() => null);
|
|
844
|
+
const unknown = unknownEnvNames();
|
|
845
|
+
const ready = c.discovery.startsWith("OK") && c.clientId;
|
|
787
846
|
return {
|
|
788
847
|
blocks: [
|
|
789
848
|
{ type: "header", text: "Status" },
|
|
790
|
-
|
|
791
|
-
? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their
|
|
792
|
-
: { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload." },
|
|
849
|
+
ready
|
|
850
|
+
? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their store customer account." }
|
|
851
|
+
: { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload. Setup walks through each one." },
|
|
852
|
+
...customDomainBlocks(config),
|
|
853
|
+
{ type: "header", text: "Shopify settings" },
|
|
854
|
+
{ type: "context", text: "Copy these into the store: the first three into the Customer Account API settings of the Headless storefront, the webhook URL into the app's webhook subscriptions." },
|
|
793
855
|
{
|
|
794
|
-
type: "
|
|
856
|
+
type: "redeemid_copy",
|
|
857
|
+
fields: [
|
|
858
|
+
{ label: "Callback URI", value: `${origin}/_emdash/api/auth/redeemid/callback` },
|
|
859
|
+
{ label: "JavaScript origin", value: origin },
|
|
860
|
+
{ label: "Logout URI", value: `${origin}/` },
|
|
861
|
+
{ label: "Webhook URL", value: `${origin}/_emdash/api/auth/redeemid/webhook` },
|
|
862
|
+
],
|
|
863
|
+
},
|
|
864
|
+
{ type: "divider" },
|
|
865
|
+
{ type: "header", text: "Environment" },
|
|
866
|
+
{ type: "context", text: "What the site reads from its environment. Secrets show only whether they're set." },
|
|
867
|
+
{
|
|
868
|
+
type: "redeemid_copy",
|
|
795
869
|
fields: [
|
|
796
|
-
{ label: "Store domain
|
|
797
|
-
{ label: "Store discovery", value: c.discovery },
|
|
798
|
-
{ label: "Customer Account API client ID", value:
|
|
799
|
-
{ label: "Webhook signing secret", value:
|
|
800
|
-
{ label: "Admin API app client ID (staff tools)", value:
|
|
801
|
-
{ label: "
|
|
802
|
-
{ label: "
|
|
803
|
-
{ label: "Last webhook", value: delivery ?? "None received yet" },
|
|
804
|
-
{ label: "Replacement text", value: textSummary(config) },
|
|
805
|
-
...(
|
|
870
|
+
{ label: "Store domain", value: c.domain || "Missing", hint: `${ENV.storeDomain} or shop`, copy: Boolean(c.domain) },
|
|
871
|
+
{ label: "Store discovery", value: c.discovery, copy: false },
|
|
872
|
+
{ label: "Customer Account API client ID", value: set(c.clientId), hint: ENV.clientId, copy: false },
|
|
873
|
+
{ label: "Webhook signing secret", value: set(c.webhookSecret), hint: ENV.webhookSecret, copy: false },
|
|
874
|
+
{ label: "Admin API app client ID (staff tools)", value: set(Boolean(env(ENV.adminClientId))), hint: ENV.adminClientId, copy: false },
|
|
875
|
+
{ label: "Storefront API access", value: c.storefront, hint: `${ENV.storefrontPrivateToken} or ${ENV.storefrontPublicToken}`, copy: false },
|
|
876
|
+
{ label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (secret missing)"}` : "Off", hint: config.events?.url ? ENV.eventsSecret : undefined, copy: Boolean(config.events?.url) },
|
|
877
|
+
{ label: "Last webhook", value: delivery ?? "None received yet", copy: false },
|
|
878
|
+
{ label: "Replacement text", value: textSummary(config), copy: false },
|
|
879
|
+
...(unknown.length ? [{ label: "Set but not used (check the name)", value: unknown.join(", "), copy: false }] : []),
|
|
806
880
|
],
|
|
807
881
|
},
|
|
808
882
|
...(textProblems(config).length
|
|
@@ -818,22 +892,9 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
818
892
|
},
|
|
819
893
|
]
|
|
820
894
|
: []),
|
|
821
|
-
...customDomainBlocks(config),
|
|
822
|
-
...templateBlocks(config),
|
|
823
|
-
{ type: "context", text: "Setting up a store step by step is under Setup. Deliveries, their health and the webhook subscriptions are under Webhooks; everything RedeemID did is under App events." },
|
|
824
|
-
{ type: "divider" },
|
|
825
|
-
{ type: "header", text: "Shopify settings" },
|
|
826
|
-
{
|
|
827
|
-
type: "fields",
|
|
828
|
-
fields: [
|
|
829
|
-
{ label: "Callback URI", value: `${origin}/_emdash/api/auth/redeemid/callback` },
|
|
830
|
-
{ label: "JavaScript origin", value: origin },
|
|
831
|
-
{ label: "Logout URI", value: `${origin}/` },
|
|
832
|
-
{ label: "Webhook URL", value: `${origin}/_emdash/api/auth/redeemid/webhook` },
|
|
833
|
-
],
|
|
834
|
-
},
|
|
835
895
|
{ type: "divider" },
|
|
836
|
-
{ type: "
|
|
896
|
+
{ type: "header", text: "Templates" },
|
|
897
|
+
...templateBlocks(config),
|
|
837
898
|
],
|
|
838
899
|
};
|
|
839
900
|
}
|
|
@@ -903,19 +964,184 @@ function templateBlocks(config: RedeemIDConfig): unknown[] {
|
|
|
903
964
|
];
|
|
904
965
|
}
|
|
905
966
|
|
|
967
|
+
/** Counts the Overview and the dashboard card share. */
|
|
968
|
+
async function summary(config: RedeemIDConfig, ctx: PluginContext) {
|
|
969
|
+
const [c, members, check, delivery, requests] = await Promise.all([
|
|
970
|
+
checks(config),
|
|
971
|
+
allMembers().catch(() => [] as MemberRecord[]),
|
|
972
|
+
lastCheck(ctx.kv).catch(() => null),
|
|
973
|
+
memberStores()
|
|
974
|
+
.then((s) => s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 1 }))
|
|
975
|
+
.then((r) => r.items[0]?.data ?? null)
|
|
976
|
+
.catch(() => null),
|
|
977
|
+
memberStores()
|
|
978
|
+
.then((s) => recentRequests(s))
|
|
979
|
+
.catch(() => []),
|
|
980
|
+
]);
|
|
981
|
+
const levels = config.levels ?? [];
|
|
982
|
+
return {
|
|
983
|
+
signIn: c.discovery.startsWith("OK") && c.clientId,
|
|
984
|
+
members,
|
|
985
|
+
active: members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).length > 0).length,
|
|
986
|
+
signedIn: members.filter((m) => m.userId).length,
|
|
987
|
+
check,
|
|
988
|
+
delivery,
|
|
989
|
+
openRequests: requests.filter((r) => r.data.status === "open").length,
|
|
990
|
+
levels,
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
type Summary = Awaited<ReturnType<typeof summary>>;
|
|
995
|
+
|
|
996
|
+
function webhookWord(s: Summary): { value: string; tone: "ok" | "warn" | "error" } {
|
|
997
|
+
if (!s.check) return { value: "Unchecked", tone: "warn" };
|
|
998
|
+
return s.check.problems.length ? { value: `${s.check.problems.length} to fix`, tone: "error" } : { value: "Healthy", tone: "ok" };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** "3 h ago" for a recent time. */
|
|
1002
|
+
function ago(iso: string | null | undefined, now = Date.now()): string {
|
|
1003
|
+
if (!iso) return "";
|
|
1004
|
+
const ms = now - Date.parse(iso);
|
|
1005
|
+
if (!Number.isFinite(ms)) return "";
|
|
1006
|
+
if (ms < 60_000) return "just now";
|
|
1007
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ago`;
|
|
1008
|
+
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} h ago`;
|
|
1009
|
+
const d = Math.floor(ms / 86_400_000);
|
|
1010
|
+
return d === 1 ? "yesterday" : `${d} days ago`;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
const page = (path: string) => ({ kind: "plugin-page" as const, path });
|
|
1014
|
+
const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1015
|
+
|
|
1016
|
+
/** The dashboard card: what needs a look, the counts, and the way in. */
|
|
906
1017
|
async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
|
|
907
|
-
const
|
|
908
|
-
const
|
|
909
|
-
const
|
|
1018
|
+
const s = await summary(config, ctx);
|
|
1019
|
+
const hooks = webhookWord(s);
|
|
1020
|
+
const alerts: unknown[] = [];
|
|
1021
|
+
if (!s.signIn) alerts.push({ type: "banner", variant: "error", title: "Sign-in isn't ready", description: "Members can't sign in until the store settings are complete. See Setup." });
|
|
1022
|
+
if (s.check?.problems.length) alerts.push({ type: "banner", variant: "alert", title: "Webhooks need attention", description: s.check.problems[0]!.text });
|
|
1023
|
+
if (s.openRequests) alerts.push({ type: "banner", variant: "alert", title: `${s.openRequests} privacy ${s.openRequests === 1 ? "request is" : "requests are"} open`, description: "Staff need to finish them under Privacy." });
|
|
910
1024
|
return {
|
|
911
1025
|
blocks: [
|
|
912
|
-
...
|
|
1026
|
+
...alerts,
|
|
1027
|
+
{
|
|
1028
|
+
type: "stats",
|
|
1029
|
+
items: [
|
|
1030
|
+
{ label: "Members", value: String(s.members.length), description: `${s.active} with a level` },
|
|
1031
|
+
{ label: "Sign-in", value: s.signIn ? "Ready" : "Not ready" },
|
|
1032
|
+
{ label: "Webhooks", value: hooks.value, description: s.delivery ? `Last ${ago(s.delivery.receivedAt)}` : "None yet" },
|
|
1033
|
+
],
|
|
1034
|
+
},
|
|
913
1035
|
{
|
|
914
1036
|
type: "fields",
|
|
915
1037
|
fields: [
|
|
916
|
-
{ label: "
|
|
917
|
-
{ label: "
|
|
918
|
-
|
|
1038
|
+
{ label: "Signed in here", value: String(s.signedIn) },
|
|
1039
|
+
{ label: "Levels", value: s.levels.map((l) => l.name).join(", ") || "None yet" },
|
|
1040
|
+
],
|
|
1041
|
+
},
|
|
1042
|
+
{
|
|
1043
|
+
type: "actions",
|
|
1044
|
+
elements: [
|
|
1045
|
+
{ type: "link", label: "Overview", target: page("/"), appearance: "primary" },
|
|
1046
|
+
{ type: "link", label: "Members", target: page("/members"), appearance: "secondary" },
|
|
1047
|
+
{ type: "link", label: "Access", target: page("/access"), appearance: "secondary" },
|
|
1048
|
+
{ type: "link", label: s.signIn && !s.check?.problems.length ? "Webhooks" : "Setup", target: page(s.signIn && !s.check?.problems.length ? "/connection/webhooks" : "/connection/setup"), appearance: "secondary" },
|
|
1049
|
+
],
|
|
1050
|
+
},
|
|
1051
|
+
],
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/** The first page: what needs doing, the numbers, recent activity and where to go. */
|
|
1056
|
+
async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
|
|
1057
|
+
const st = await memberStores();
|
|
1058
|
+
const [s, facts, log] = await Promise.all([
|
|
1059
|
+
summary(config, ctx),
|
|
1060
|
+
setupFacts(config, st, siteOrigin(ctx)).catch(() => null),
|
|
1061
|
+
st.log.query({ orderBy: { at: "desc" }, limit: 8 }).catch(() => ({ items: [] as Array<{ data: Parameters<typeof eventRow>[0] }> })),
|
|
1062
|
+
]);
|
|
1063
|
+
const steps = facts ? setupSteps(facts) : [];
|
|
1064
|
+
const required = steps.filter((x) => x.state !== "optional");
|
|
1065
|
+
const done = required.filter((x) => x.state === "done").length;
|
|
1066
|
+
const hooks = webhookWord(s);
|
|
1067
|
+
const rules = config.rules ?? [];
|
|
1068
|
+
const counts = s.levels.map((l) => ({
|
|
1069
|
+
level: l.name,
|
|
1070
|
+
members: s.members.filter((m) => m.status === "enabled" && levelsFor(m.tags, s.levels).includes(l.slug)).length,
|
|
1071
|
+
includes: (l.includes ?? []).map((x) => s.levels.find((o) => o.slug === x)?.name ?? x).join(", "),
|
|
1072
|
+
features: (config.features ?? []).filter((f) => f.levels?.includes(l.slug)).map((f) => f.name).join(", "),
|
|
1073
|
+
rules: rules.filter((r) => r.levels.includes(l.slug)).length,
|
|
1074
|
+
folders: (config.files ?? []).filter((f) => f.levels?.includes(l.slug)).length,
|
|
1075
|
+
}));
|
|
1076
|
+
const domain = storeDomain(config);
|
|
1077
|
+
const shop = domain && !domain.startsWith("http") ? `https://${domain.replace(/^https?:\/\//, "")}` : null;
|
|
1078
|
+
const alerts: unknown[] = [];
|
|
1079
|
+
if (required.length && done < required.length)
|
|
1080
|
+
alerts.push({ type: "banner", variant: s.signIn ? "default" : "alert", title: `Setup: ${done} of ${required.length} steps done`, description: `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}.` });
|
|
1081
|
+
if (s.check?.problems.length) alerts.push({ type: "banner", variant: "alert", title: "Webhooks need attention", description: s.check.problems.map((p) => p.text).join(" ") });
|
|
1082
|
+
if (s.openRequests) alerts.push({ type: "banner", variant: "alert", title: `${s.openRequests} privacy ${s.openRequests === 1 ? "request is" : "requests are"} open`, description: "Finish them under Privacy." });
|
|
1083
|
+
return {
|
|
1084
|
+
blocks: [
|
|
1085
|
+
{ type: "header", text: "RedeemID" },
|
|
1086
|
+
{ type: "context", text: `Members sign in with their store customer account, and their Shopify customer tags decide what they read. Version ${VERSION}.` },
|
|
1087
|
+
...alerts,
|
|
1088
|
+
{
|
|
1089
|
+
type: "redeemid_cards",
|
|
1090
|
+
items: [
|
|
1091
|
+
{ title: "Sign-in", value: s.signIn ? "Ready" : "Not ready", tone: s.signIn ? "ok" : "error", text: domain ? `Store: ${domain.replace(/^https?:\/\//, "")}` : "No store set", link: page("/connection/status"), label: "Status" },
|
|
1092
|
+
{ title: "Members", value: String(s.members.length), text: `${s.active} with a level, ${s.signedIn} signed in here`, link: page("/members"), label: "Members" },
|
|
1093
|
+
{ title: "Webhooks", value: hooks.value, tone: hooks.tone, text: s.delivery ? `Last delivery ${ago(s.delivery.receivedAt)} (${s.delivery.topic})` : "None received yet", link: page("/connection/webhooks"), label: "Webhooks" },
|
|
1094
|
+
{ title: "Access", value: count(s.levels.length, "level"), text: [count(rules.length, "rule"), count((config.features ?? []).length, "feature"), count((config.files ?? []).length, "protected folder")].join(", "), link: page("/access"), label: "Access" },
|
|
1095
|
+
{ title: "Privacy", value: s.openRequests ? `${s.openRequests} open` : "Nothing open", tone: s.openRequests ? "warn" : undefined, text: "Exports and erasures from members, staff and Shopify", link: page("/privacy"), label: "Privacy" },
|
|
1096
|
+
{ title: "Setup", value: required.length ? `${done} of ${required.length}` : "Unknown", tone: required.length && done === required.length ? "ok" : "warn", text: done === required.length ? "Every step is done" : `Next: ${required.find((x) => x.state !== "done")?.title ?? ""}`, link: page("/connection/setup"), label: "Setup" },
|
|
1097
|
+
],
|
|
1098
|
+
},
|
|
1099
|
+
{ type: "header", text: "Levels" },
|
|
1100
|
+
counts.length
|
|
1101
|
+
? {
|
|
1102
|
+
type: "table",
|
|
1103
|
+
page_action_id: "overview_levels",
|
|
1104
|
+
columns: [
|
|
1105
|
+
{ key: "level", label: "Level" },
|
|
1106
|
+
{ key: "members", label: "Members", format: "number" },
|
|
1107
|
+
{ key: "includes", label: "Includes" },
|
|
1108
|
+
{ key: "features", label: "Features" },
|
|
1109
|
+
{ key: "rules", label: "Rules", format: "number" },
|
|
1110
|
+
{ key: "folders", label: "Folders", format: "number" },
|
|
1111
|
+
],
|
|
1112
|
+
rows: counts,
|
|
1113
|
+
}
|
|
1114
|
+
: { type: "empty", title: "No levels yet", description: "Levels name the readers who hold some Shopify tags.", size: "sm", actions: [{ type: "link", label: "Add a level", target: page("/access"), appearance: "primary" }] },
|
|
1115
|
+
...(counts.length ? [{ type: "actions", elements: [{ type: "link", label: "Explore the access map", target: page("/access"), appearance: "secondary" }] }] : []),
|
|
1116
|
+
{ type: "header", text: "Recent activity" },
|
|
1117
|
+
{
|
|
1118
|
+
type: "table",
|
|
1119
|
+
page_action_id: "overview_events",
|
|
1120
|
+
empty_text: "Nothing recorded yet.",
|
|
1121
|
+
columns: [
|
|
1122
|
+
{ key: "event", label: "Event" },
|
|
1123
|
+
{ key: "about", label: "About" },
|
|
1124
|
+
{ key: "result", label: "Result", format: "badge" },
|
|
1125
|
+
{ key: "at", label: "When", format: "relative_time" },
|
|
1126
|
+
],
|
|
1127
|
+
rows: log.items.map((r) => {
|
|
1128
|
+
const { event, about, result, at } = eventRow(r.data);
|
|
1129
|
+
return { event, about, result, at };
|
|
1130
|
+
}),
|
|
1131
|
+
},
|
|
1132
|
+
{ type: "actions", elements: [{ type: "link", label: "All app events", target: page("/connection/events"), appearance: "secondary" }, { type: "link", label: "Reports", target: page("/reports"), appearance: "secondary" }] },
|
|
1133
|
+
{ type: "header", text: "Resources" },
|
|
1134
|
+
{
|
|
1135
|
+
type: "redeemid_cards",
|
|
1136
|
+
items: [
|
|
1137
|
+
{ title: "Documentation", text: "Setting up the store, levels, rules, blocks and templates.", link: { kind: "external", url: "https://www.redeemid.com/docs/emdash/" }, label: "Read the docs" },
|
|
1138
|
+
...(shop
|
|
1139
|
+
? [
|
|
1140
|
+
{ title: "Customers in Shopify", text: "Tags, subscriptions and orders live on the Shopify customer.", link: { kind: "external", url: `${shop}/admin/customers` }, label: "Open Shopify" },
|
|
1141
|
+
{ title: "Shopify Flow", text: "Add and remove customer tags when orders, subscriptions or dates change.", link: { kind: "external", url: `${shop}/admin/apps/flow` }, label: "Open Flow" },
|
|
1142
|
+
]
|
|
1143
|
+
: []),
|
|
1144
|
+
{ title: "Report a problem", text: "Bugs and questions about RedeemID for EmDash.", link: { kind: "external", url: "https://github.com/webmultipliers/redeemid-emdash/issues" }, label: "Open an issue" },
|
|
919
1145
|
],
|
|
920
1146
|
},
|
|
921
1147
|
],
|