@redeemid/emdash 0.0.30 → 0.0.32
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 +3 -3
- package/docs/access.md +7 -5
- package/docs/content.md +1 -1
- package/docs/developer-api.md +2 -2
- 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 +4 -4
- package/docs/staff.md +21 -9
- 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 +16 -9
- package/src/monitor.ts +20 -33
- package/src/plugin.ts +327 -81
- package/src/setup.ts +33 -38
- 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, EVENTS_TABS, 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,
|
|
44
|
+
import { nextStep, requiredSteps, setupFacts, setupPanels, setupSteps, STEP_TABS, 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,
|
|
47
|
+
import { eventRow, eventsPage, healthCheck, HEALTH_TASK, lastCheck, parseFilter, reportsPage, saveMonitor, WEBHOOK_PATH, webhookPanels, type MonitorEnv } 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,19 @@ 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 and Events, or one of their tabs by address (/connection/sign-in, /events/app).
|
|
293
|
+
if (i.page === "/connection" || i.page?.startsWith("/connection/")) {
|
|
287
294
|
await scheduleHealth(ctx);
|
|
288
|
-
return await
|
|
295
|
+
return await connectionPage(config, ctx, tabOf(CONNECTION_TABS, i.page.split("/")[2]));
|
|
296
|
+
}
|
|
297
|
+
if (i.page === "/events" || i.page?.startsWith("/events/")) {
|
|
298
|
+
await scheduleHealth(ctx);
|
|
299
|
+
return await eventsAdminPage(config, ctx, tabOf(EVENTS_TABS, i.page.split("/")[2]));
|
|
289
300
|
}
|
|
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
301
|
if (i.page === "/privacy") return await privacyPage(await memberStores());
|
|
296
302
|
if (i.page === "widget:status") return await statusWidget(config, ctx);
|
|
297
303
|
} catch (e) {
|
|
@@ -340,6 +346,12 @@ async function accessEnv(config: RedeemIDConfig): Promise<AccessEnv> {
|
|
|
340
346
|
saved: await loadAccess(store),
|
|
341
347
|
collections: collections.map((c) => ({ slug: c.slug, label: c.label || c.slug })),
|
|
342
348
|
taxonomies: taxonomies.map((t) => ({ name: t.name, label: t.label || t.name, collections: t.collections ?? [] })),
|
|
349
|
+
holders: async () => {
|
|
350
|
+
const levels = config.levels ?? [];
|
|
351
|
+
const out: Record<string, number> = Object.fromEntries(levels.map((l) => [l.slug, 0]));
|
|
352
|
+
for (const m of await allMembers()) if (m.status === "enabled") for (const slug of levelsFor(m.tags, levels)) out[slug] = (out[slug] ?? 0) + 1;
|
|
353
|
+
return out;
|
|
354
|
+
},
|
|
343
355
|
terms: async (taxonomy) => {
|
|
344
356
|
type Node = { slug: string; label?: string; name?: string; children?: Node[] };
|
|
345
357
|
const out: Array<{ slug: string; label: string }> = [];
|
|
@@ -474,12 +486,12 @@ function siteOrigin(ctx: PluginContext): string {
|
|
|
474
486
|
async function setupAction(
|
|
475
487
|
config: RedeemIDConfig,
|
|
476
488
|
live: () => Promise<RedeemIDConfig>,
|
|
477
|
-
ctx:
|
|
478
|
-
i: { action_id?: string; values?: Record<string, unknown> },
|
|
489
|
+
ctx: RouteContext,
|
|
490
|
+
i: { action_id?: string; value?: unknown; values?: Record<string, unknown> },
|
|
479
491
|
) {
|
|
480
|
-
const s = await memberStores();
|
|
481
492
|
const origin = siteOrigin(ctx);
|
|
482
|
-
const
|
|
493
|
+
const tab: ConnectionTab = i.action_id === "setup_register" ? "webhooks" : i.action_id === "setup_level" ? "level" : (tabOf(CONNECTION_TABS, i.value) ?? "status");
|
|
494
|
+
const page = async (toast?: { type: "success" | "error" | "info"; message: string }) => connectionPage(await live(), ctx, tab, toast);
|
|
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,43 @@ 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
|
|
591
|
+
return connectionPage(config, ctx, "webhooks", { type: failed.length ? "error" : "success", message: [done, refused].filter(Boolean).join(" ") });
|
|
580
592
|
} catch (e) {
|
|
581
|
-
return
|
|
593
|
+
return connectionPage(config, ctx, "webhooks", fail(e));
|
|
582
594
|
}
|
|
583
595
|
}
|
|
584
|
-
case "webhooks_tab": {
|
|
585
|
-
const tab = WEBHOOK_TABS.includes(i.value as WebhookTab) ? (i.value as WebhookTab) : "received";
|
|
586
|
-
return webhooksPage(monitor(), tab);
|
|
587
|
-
}
|
|
588
596
|
case "deliveries_page":
|
|
589
|
-
return
|
|
597
|
+
return eventsAdminPage(config, ctx, "webhook", { cursor: cursorOf(i.value) });
|
|
590
598
|
case "health_check": {
|
|
591
599
|
const r = await healthCheck({ ...monitor(), by: "staff" });
|
|
592
600
|
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
|
|
601
|
+
return eventsAdminPage(config, ctx, "health", {}, { type: r.problems.length ? "info" : "success", message });
|
|
594
602
|
}
|
|
595
603
|
case "monitor_save":
|
|
596
|
-
return
|
|
604
|
+
return eventsAdminPage(config, ctx, "health", {}, await saveMonitor(ctx.kv, i.values));
|
|
597
605
|
case "events_filter":
|
|
598
|
-
return eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true });
|
|
606
|
+
return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, { event: String(i.values?.event ?? "") || undefined, problems: i.values?.problems === true }) });
|
|
599
607
|
case "events_show":
|
|
600
|
-
return eventsPage(s, { event: String(i.value ?? "") || undefined });
|
|
608
|
+
return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, { event: String(i.value ?? "") || undefined }) });
|
|
601
609
|
case "events_page":
|
|
602
|
-
return eventsPage(s, parseFilter(i.block_id), cursorOf(i.value));
|
|
610
|
+
return eventsAdminPage(config, ctx, "app", { events: await eventsPage(s, parseFilter(i.block_id), cursorOf(i.value)) });
|
|
603
611
|
case "failed_resend": {
|
|
604
612
|
const sent = await resend(config, s, String(i.value ?? "")).catch(() => false);
|
|
605
|
-
return
|
|
613
|
+
return eventsAdminPage(config, ctx, "app", {}, 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
614
|
}
|
|
607
615
|
case "failed_clear":
|
|
608
616
|
await s.failedEvents.delete(String(i.value ?? ""));
|
|
609
|
-
return
|
|
617
|
+
return eventsAdminPage(config, ctx, "app", {}, { type: "success", message: "Cleared." });
|
|
610
618
|
case "failed_clear_all": {
|
|
611
619
|
const { items } = await s.failedEvents.query({ limit: 100 });
|
|
612
620
|
await s.failedEvents.deleteMany(items.map((r) => r.id));
|
|
613
|
-
return
|
|
621
|
+
return eventsAdminPage(config, ctx, "app", {}, { type: "success", message: "Failed events cleared." });
|
|
614
622
|
}
|
|
615
|
-
case "access_test":
|
|
616
|
-
return accessTest(config, i.values);
|
|
617
623
|
case "member_export": {
|
|
618
624
|
const id = String(i.value ?? "");
|
|
619
625
|
const m = await s.members.get(id);
|
|
620
626
|
if (!m) return membersPage(config);
|
|
621
|
-
return
|
|
627
|
+
return memberDetail(config, m, { type: "success", message: "Export below." }, "privacy", await exportRequest(s, id, "staff"));
|
|
622
628
|
}
|
|
623
629
|
case "member_erase": {
|
|
624
630
|
const id = String(i.value ?? "");
|
|
@@ -633,9 +639,9 @@ async function action(
|
|
|
633
639
|
if (i.action_id === "member_shopify_erase") await requestShopifyErasure(config, s, id);
|
|
634
640
|
else await cancelShopifyErasure(config, s, id);
|
|
635
641
|
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 });
|
|
642
|
+
return m ? memberDetail(config, m, { type: "success", message }, "privacy") : privacyPage(s, { type: "success", message });
|
|
637
643
|
} catch (e) {
|
|
638
|
-
return m ? memberDetail(config, m, fail(e)) : privacyPage(s, fail(e));
|
|
644
|
+
return m ? memberDetail(config, m, fail(e), "privacy") : privacyPage(s, fail(e));
|
|
639
645
|
}
|
|
640
646
|
}
|
|
641
647
|
case "privacy_done": {
|
|
@@ -779,30 +785,119 @@ function textSummary(config: RedeemIDConfig): string {
|
|
|
779
785
|
return ignored ? `${all - ignored} in use, ${ignored} ignored` : `${all} in use`;
|
|
780
786
|
}
|
|
781
787
|
|
|
788
|
+
type ConnectionTab = (typeof CONNECTION_TABS)[number];
|
|
789
|
+
type EventsTab = (typeof EVENTS_TABS)[number];
|
|
790
|
+
type Toast = { type: "success" | "error" | "info"; message: string };
|
|
791
|
+
|
|
792
|
+
/** A tab name from an address or a button value, if it's one of these. */
|
|
793
|
+
function tabOf<T extends string>(tabs: readonly T[], v: unknown): T | undefined {
|
|
794
|
+
return tabs.includes(v as T) ? (v as T) : undefined;
|
|
795
|
+
}
|
|
796
|
+
const withoutHeader = (b: { blocks: unknown[] }) => b.blocks.filter((x, n) => !(n === 0 && (x as { type?: string }).type === "header"));
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* How this site talks to Shopify: Status (the values to copy into Shopify
|
|
800
|
+
* and what the site reads from its environment), then one tab per setup
|
|
801
|
+
* step, each with a check mark once done. Webhooks also holds the app's
|
|
802
|
+
* subscriptions. Opens on the first step not done, or on Status.
|
|
803
|
+
*/
|
|
804
|
+
async function connectionPage(config: RedeemIDConfig, ctx: RouteContext, tab?: ConnectionTab, toast?: Toast) {
|
|
805
|
+
const s = await memberStores();
|
|
806
|
+
const [facts, status, hooks] = await Promise.all([setupFacts(config, s, siteOrigin(ctx)), statusPage(config, ctx.request), webhookPanels(monitorEnv(config, s, ctx))]);
|
|
807
|
+
const panels = setupPanels(facts);
|
|
808
|
+
const next = nextStep(facts);
|
|
809
|
+
const steps = requiredSteps(setupSteps(facts));
|
|
810
|
+
const open = tab ?? (next ? (STEP_TABS[next.key] as ConnectionTab) : "status");
|
|
811
|
+
const progress = next
|
|
812
|
+
? [{ type: "banner", variant: "alert", title: `Setup: ${steps.filter((x) => x.state === "done").length} of ${steps.length} steps done`, description: `Work through the tabs. Each one checks the site as it is, so set a secret, redeploy or restart, and press Check again. Next: ${next.title}.` }]
|
|
813
|
+
: [];
|
|
814
|
+
// The Webhooks step has its own Register button while it's to do.
|
|
815
|
+
const ownButton = panels.some((p) => p.tab === "webhooks" && JSON.stringify(p.blocks).includes('"setup_register"'));
|
|
816
|
+
const subscriptions = ownButton ? hooks.subscriptions.filter((b) => !JSON.stringify(b).includes('"webhooks_register"')) : hooks.subscriptions;
|
|
817
|
+
return {
|
|
818
|
+
blocks: [
|
|
819
|
+
{ type: "header", text: "Connection" },
|
|
820
|
+
{ type: "context", text: "How this site talks to Shopify: the values to copy into the store, and each setup step." },
|
|
821
|
+
{
|
|
822
|
+
type: "tab",
|
|
823
|
+
default_tab: CONNECTION_TABS.indexOf(open),
|
|
824
|
+
panels: [
|
|
825
|
+
{ label: "Status", blocks: [...progress, ...withoutHeader(status)] },
|
|
826
|
+
...panels.map((p) => (p.tab === "webhooks" ? { label: p.label, blocks: [...p.blocks, { type: "divider" }, { type: "header", text: "Subscriptions" }, ...subscriptions] } : { label: p.label, blocks: p.blocks })),
|
|
827
|
+
],
|
|
828
|
+
},
|
|
829
|
+
],
|
|
830
|
+
...(toast ? { toast } : {}),
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* What happened: webhook deliveries from Shopify, what RedeemID did (App),
|
|
836
|
+
* and whether deliveries are healthy. An action passes the App panel it
|
|
837
|
+
* rebuilt (a filter or a page) or a deliveries cursor.
|
|
838
|
+
*/
|
|
839
|
+
async function eventsAdminPage(config: RedeemIDConfig, ctx: RouteContext, tab: EventsTab = "webhook", given: { events?: { blocks: unknown[] }; cursor?: string } = {}, toast?: Toast) {
|
|
840
|
+
const s = await memberStores();
|
|
841
|
+
const [hooks, events] = await Promise.all([webhookPanels(monitorEnv(config, s, ctx), given.cursor), given.events ?? eventsPage(s)]);
|
|
842
|
+
return {
|
|
843
|
+
blocks: [
|
|
844
|
+
{ type: "header", text: "Events" },
|
|
845
|
+
{ type: "context", text: "What Shopify sent, what RedeemID did with it, and whether deliveries are healthy." },
|
|
846
|
+
{
|
|
847
|
+
type: "tab",
|
|
848
|
+
default_tab: EVENTS_TABS.indexOf(tab),
|
|
849
|
+
panels: [
|
|
850
|
+
{ label: "Webhook", blocks: hooks.received },
|
|
851
|
+
{ label: "App", blocks: events.blocks },
|
|
852
|
+
{ label: hooks.attention ? "Health (!)" : "Health", blocks: hooks.health },
|
|
853
|
+
],
|
|
854
|
+
},
|
|
855
|
+
],
|
|
856
|
+
...(toast ? { toast } : {}),
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
782
860
|
async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
783
861
|
const c = await checks(config);
|
|
784
862
|
const origin = new URL(request.url).origin;
|
|
785
|
-
const
|
|
863
|
+
const set = (v: boolean) => (v ? "Set" : "Missing");
|
|
786
864
|
const delivery = await lastDelivery().catch(() => null);
|
|
865
|
+
const unknown = unknownEnvNames();
|
|
866
|
+
const ready = c.discovery.startsWith("OK") && c.clientId;
|
|
787
867
|
return {
|
|
788
868
|
blocks: [
|
|
789
869
|
{ 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." },
|
|
870
|
+
ready
|
|
871
|
+
? { type: "banner", variant: "default", title: "Sign-in is ready", description: "Members can sign in with their store customer account." }
|
|
872
|
+
: { type: "banner", variant: "alert", title: "Sign-in isn't ready", description: "Set the missing values below as environment secrets, then reload. The tabs after Status walk through each one." },
|
|
873
|
+
...customDomainBlocks(config),
|
|
874
|
+
{ type: "header", text: "Shopify settings" },
|
|
875
|
+
{ 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
876
|
{
|
|
794
|
-
type: "
|
|
877
|
+
type: "redeemid_copy",
|
|
795
878
|
fields: [
|
|
796
|
-
{ label: "
|
|
797
|
-
{ label: "
|
|
798
|
-
{ label: "
|
|
799
|
-
{ label: "Webhook
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
879
|
+
{ label: "Callback URI", value: `${origin}/_emdash/api/auth/redeemid/callback` },
|
|
880
|
+
{ label: "JavaScript origin", value: origin },
|
|
881
|
+
{ label: "Logout URI", value: `${origin}/` },
|
|
882
|
+
{ label: "Webhook URL", value: `${origin}/_emdash/api/auth/redeemid/webhook` },
|
|
883
|
+
],
|
|
884
|
+
},
|
|
885
|
+
{ type: "divider" },
|
|
886
|
+
{ type: "header", text: "Environment" },
|
|
887
|
+
{ type: "context", text: "What the site reads from its environment. Secrets show only whether they're set." },
|
|
888
|
+
{
|
|
889
|
+
type: "redeemid_copy",
|
|
890
|
+
fields: [
|
|
891
|
+
{ label: "Store domain", value: c.domain || "Missing", hint: `${ENV.storeDomain} or shop`, copy: Boolean(c.domain) },
|
|
892
|
+
{ label: "Store discovery", value: c.discovery, copy: false },
|
|
893
|
+
{ label: "Customer Account API client ID", value: set(c.clientId), hint: ENV.clientId, copy: false },
|
|
894
|
+
{ label: "Webhook signing secret", value: set(c.webhookSecret), hint: ENV.webhookSecret, copy: false },
|
|
895
|
+
{ label: "Admin API app client ID (staff tools)", value: set(Boolean(env(ENV.adminClientId))), hint: ENV.adminClientId, copy: false },
|
|
896
|
+
{ label: "Storefront API access", value: c.storefront, hint: `${ENV.storefrontPrivateToken} or ${ENV.storefrontPublicToken}`, copy: false },
|
|
897
|
+
{ 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) },
|
|
898
|
+
{ label: "Last webhook", value: delivery ?? "None received yet", copy: false },
|
|
899
|
+
{ label: "Replacement text", value: textSummary(config), copy: false },
|
|
900
|
+
...(unknown.length ? [{ label: "Set but not used (check the name)", value: unknown.join(", "), copy: false }] : []),
|
|
806
901
|
],
|
|
807
902
|
},
|
|
808
903
|
...(textProblems(config).length
|
|
@@ -818,22 +913,9 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
|
|
|
818
913
|
},
|
|
819
914
|
]
|
|
820
915
|
: []),
|
|
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
916
|
{ type: "divider" },
|
|
836
|
-
{ type: "
|
|
917
|
+
{ type: "header", text: "Templates" },
|
|
918
|
+
...templateBlocks(config),
|
|
837
919
|
],
|
|
838
920
|
};
|
|
839
921
|
}
|
|
@@ -903,19 +985,183 @@ function templateBlocks(config: RedeemIDConfig): unknown[] {
|
|
|
903
985
|
];
|
|
904
986
|
}
|
|
905
987
|
|
|
988
|
+
/** Counts the Overview and the dashboard card share. */
|
|
989
|
+
async function summary(config: RedeemIDConfig, ctx: PluginContext) {
|
|
990
|
+
const [c, members, check, delivery, requests] = await Promise.all([
|
|
991
|
+
checks(config),
|
|
992
|
+
allMembers().catch(() => [] as MemberRecord[]),
|
|
993
|
+
lastCheck(ctx.kv).catch(() => null),
|
|
994
|
+
memberStores()
|
|
995
|
+
.then((s) => s.deliveries.query({ orderBy: { receivedAt: "desc" }, limit: 1 }))
|
|
996
|
+
.then((r) => r.items[0]?.data ?? null)
|
|
997
|
+
.catch(() => null),
|
|
998
|
+
memberStores()
|
|
999
|
+
.then((s) => recentRequests(s))
|
|
1000
|
+
.catch(() => []),
|
|
1001
|
+
]);
|
|
1002
|
+
const levels = config.levels ?? [];
|
|
1003
|
+
return {
|
|
1004
|
+
signIn: c.discovery.startsWith("OK") && c.clientId,
|
|
1005
|
+
members,
|
|
1006
|
+
active: members.filter((m) => m.status === "enabled" && levelsFor(m.tags, levels).length > 0).length,
|
|
1007
|
+
signedIn: members.filter((m) => m.userId).length,
|
|
1008
|
+
check,
|
|
1009
|
+
delivery,
|
|
1010
|
+
openRequests: requests.filter((r) => r.data.status === "open").length,
|
|
1011
|
+
levels,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
type Summary = Awaited<ReturnType<typeof summary>>;
|
|
1016
|
+
|
|
1017
|
+
function webhookWord(s: Summary): { value: string; tone: "ok" | "warn" | "error" } {
|
|
1018
|
+
if (!s.check) return { value: "Unchecked", tone: "warn" };
|
|
1019
|
+
return s.check.problems.length ? { value: `${s.check.problems.length} to fix`, tone: "error" } : { value: "Healthy", tone: "ok" };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** "3 h ago" for a recent time. */
|
|
1023
|
+
function ago(iso: string | null | undefined, now = Date.now()): string {
|
|
1024
|
+
if (!iso) return "";
|
|
1025
|
+
const ms = now - Date.parse(iso);
|
|
1026
|
+
if (!Number.isFinite(ms)) return "";
|
|
1027
|
+
if (ms < 60_000) return "just now";
|
|
1028
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ago`;
|
|
1029
|
+
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} h ago`;
|
|
1030
|
+
const d = Math.floor(ms / 86_400_000);
|
|
1031
|
+
return d === 1 ? "yesterday" : `${d} days ago`;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const page = (path: string) => ({ kind: "plugin-page" as const, path });
|
|
1035
|
+
const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1036
|
+
|
|
1037
|
+
/** The dashboard card: what needs a look, the counts, and the way in. */
|
|
906
1038
|
async function statusWidget(config: RedeemIDConfig, ctx: PluginContext) {
|
|
907
|
-
const
|
|
908
|
-
const
|
|
909
|
-
const
|
|
1039
|
+
const s = await summary(config, ctx);
|
|
1040
|
+
const hooks = webhookWord(s);
|
|
1041
|
+
const alerts: unknown[] = [];
|
|
1042
|
+
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." });
|
|
1043
|
+
if (s.check?.problems.length) alerts.push({ type: "banner", variant: "alert", title: "Webhooks need attention", description: s.check.problems[0]!.text });
|
|
1044
|
+
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
1045
|
return {
|
|
911
1046
|
blocks: [
|
|
912
|
-
...
|
|
1047
|
+
...alerts,
|
|
1048
|
+
{
|
|
1049
|
+
type: "stats",
|
|
1050
|
+
items: [
|
|
1051
|
+
{ label: "Members", value: String(s.members.length), description: `${s.active} with a level` },
|
|
1052
|
+
{ label: "Sign-in", value: s.signIn ? "Ready" : "Not ready" },
|
|
1053
|
+
{ label: "Webhooks", value: hooks.value, description: s.delivery ? `Last ${ago(s.delivery.receivedAt)}` : "None yet" },
|
|
1054
|
+
],
|
|
1055
|
+
},
|
|
913
1056
|
{
|
|
914
1057
|
type: "fields",
|
|
915
1058
|
fields: [
|
|
916
|
-
{ label: "
|
|
917
|
-
{ label: "
|
|
918
|
-
|
|
1059
|
+
{ label: "Signed in here", value: String(s.signedIn) },
|
|
1060
|
+
{ label: "Levels", value: s.levels.map((l) => l.name).join(", ") || "None yet" },
|
|
1061
|
+
],
|
|
1062
|
+
},
|
|
1063
|
+
{
|
|
1064
|
+
type: "actions",
|
|
1065
|
+
elements: [
|
|
1066
|
+
{ type: "link", label: "Overview", target: page("/"), appearance: "primary" },
|
|
1067
|
+
{ type: "link", label: "Members", target: page("/members"), appearance: "secondary" },
|
|
1068
|
+
{ type: "link", label: "Access", target: page("/access"), appearance: "secondary" },
|
|
1069
|
+
{ type: "link", label: s.signIn && !s.check?.problems.length ? "Events" : "Connection", target: page(s.signIn && !s.check?.problems.length ? "/events/webhook" : "/connection"), appearance: "secondary" },
|
|
1070
|
+
],
|
|
1071
|
+
},
|
|
1072
|
+
],
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** The first page: what needs doing, the numbers, recent activity and where to go. */
|
|
1077
|
+
async function overviewPage(config: RedeemIDConfig, ctx: PluginContext) {
|
|
1078
|
+
const st = await memberStores();
|
|
1079
|
+
const [s, facts, log] = await Promise.all([
|
|
1080
|
+
summary(config, ctx),
|
|
1081
|
+
setupFacts(config, st, siteOrigin(ctx)).catch(() => null),
|
|
1082
|
+
st.log.query({ orderBy: { at: "desc" }, limit: 8 }).catch(() => ({ items: [] as Array<{ data: Parameters<typeof eventRow>[0] }> })),
|
|
1083
|
+
]);
|
|
1084
|
+
const required = facts ? requiredSteps(setupSteps(facts)) : [];
|
|
1085
|
+
const done = required.filter((x) => x.state === "done").length;
|
|
1086
|
+
const hooks = webhookWord(s);
|
|
1087
|
+
const rules = config.rules ?? [];
|
|
1088
|
+
const counts = s.levels.map((l) => ({
|
|
1089
|
+
level: l.name,
|
|
1090
|
+
members: s.members.filter((m) => m.status === "enabled" && levelsFor(m.tags, s.levels).includes(l.slug)).length,
|
|
1091
|
+
includes: (l.includes ?? []).map((x) => s.levels.find((o) => o.slug === x)?.name ?? x).join(", "),
|
|
1092
|
+
features: (config.features ?? []).filter((f) => f.levels?.includes(l.slug)).map((f) => f.name).join(", "),
|
|
1093
|
+
rules: rules.filter((r) => r.levels.includes(l.slug)).length,
|
|
1094
|
+
folders: (config.files ?? []).filter((f) => f.levels?.includes(l.slug)).length,
|
|
1095
|
+
}));
|
|
1096
|
+
const domain = storeDomain(config);
|
|
1097
|
+
const shop = domain && !domain.startsWith("http") ? `https://${domain.replace(/^https?:\/\//, "")}` : null;
|
|
1098
|
+
const alerts: unknown[] = [];
|
|
1099
|
+
if (required.length && done < required.length)
|
|
1100
|
+
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 ?? ""}.` });
|
|
1101
|
+
if (s.check?.problems.length) alerts.push({ type: "banner", variant: "alert", title: "Webhooks need attention", description: s.check.problems.map((p) => p.text).join(" ") });
|
|
1102
|
+
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." });
|
|
1103
|
+
return {
|
|
1104
|
+
blocks: [
|
|
1105
|
+
{ type: "header", text: "RedeemID" },
|
|
1106
|
+
{ type: "context", text: `Members sign in with their store customer account, and their Shopify customer tags decide what they read. Version ${VERSION}.` },
|
|
1107
|
+
...alerts,
|
|
1108
|
+
{
|
|
1109
|
+
type: "redeemid_cards",
|
|
1110
|
+
items: [
|
|
1111
|
+
{ 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" },
|
|
1112
|
+
{ title: "Members", value: String(s.members.length), text: `${s.active} with a level, ${s.signedIn} signed in here`, link: page("/members"), label: "Members" },
|
|
1113
|
+
{ 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("/events/webhook"), label: "Webhooks" },
|
|
1114
|
+
{ 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" },
|
|
1115
|
+
{ 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" },
|
|
1116
|
+
{ 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"), label: "Setup" },
|
|
1117
|
+
],
|
|
1118
|
+
},
|
|
1119
|
+
{ type: "header", text: "Levels" },
|
|
1120
|
+
counts.length
|
|
1121
|
+
? {
|
|
1122
|
+
type: "table",
|
|
1123
|
+
page_action_id: "overview_levels",
|
|
1124
|
+
columns: [
|
|
1125
|
+
{ key: "level", label: "Level" },
|
|
1126
|
+
{ key: "members", label: "Members", format: "number" },
|
|
1127
|
+
{ key: "includes", label: "Includes" },
|
|
1128
|
+
{ key: "features", label: "Features" },
|
|
1129
|
+
{ key: "rules", label: "Rules", format: "number" },
|
|
1130
|
+
{ key: "folders", label: "Folders", format: "number" },
|
|
1131
|
+
],
|
|
1132
|
+
rows: counts,
|
|
1133
|
+
}
|
|
1134
|
+
: { 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" }] },
|
|
1135
|
+
...(counts.length ? [{ type: "actions", elements: [{ type: "link", label: "Explore the access map", target: page("/access"), appearance: "secondary" }] }] : []),
|
|
1136
|
+
{ type: "header", text: "Recent activity" },
|
|
1137
|
+
{
|
|
1138
|
+
type: "table",
|
|
1139
|
+
page_action_id: "overview_events",
|
|
1140
|
+
empty_text: "Nothing recorded yet.",
|
|
1141
|
+
columns: [
|
|
1142
|
+
{ key: "event", label: "Event" },
|
|
1143
|
+
{ key: "about", label: "About" },
|
|
1144
|
+
{ key: "result", label: "Result", format: "badge" },
|
|
1145
|
+
{ key: "at", label: "When", format: "relative_time" },
|
|
1146
|
+
],
|
|
1147
|
+
rows: log.items.map((r) => {
|
|
1148
|
+
const { event, about, result, at } = eventRow(r.data);
|
|
1149
|
+
return { event, about, result, at };
|
|
1150
|
+
}),
|
|
1151
|
+
},
|
|
1152
|
+
{ type: "actions", elements: [{ type: "link", label: "All app events", target: page("/events/app"), appearance: "secondary" }, { type: "link", label: "Reports", target: page("/reports"), appearance: "secondary" }] },
|
|
1153
|
+
{ type: "header", text: "Resources" },
|
|
1154
|
+
{
|
|
1155
|
+
type: "redeemid_cards",
|
|
1156
|
+
items: [
|
|
1157
|
+
{ 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" },
|
|
1158
|
+
...(shop
|
|
1159
|
+
? [
|
|
1160
|
+
{ title: "Customers in Shopify", text: "Tags, subscriptions and orders live on the Shopify customer.", link: { kind: "external", url: `${shop}/admin/customers` }, label: "Open Shopify" },
|
|
1161
|
+
{ 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" },
|
|
1162
|
+
]
|
|
1163
|
+
: []),
|
|
1164
|
+
{ 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
1165
|
],
|
|
920
1166
|
},
|
|
921
1167
|
],
|