@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/src/setup.ts CHANGED
@@ -1,19 +1,19 @@
1
1
  /**
2
- * Plugins → RedeemID → Setup: connecting a store, step by step, from the
3
- * admin alone. Each step says what to do in Shopify (with the exact values
2
+ * RedeemID → Connection: connecting a store, step by step, from the admin
3
+ * alone, one tab per step. Each step says what to do in Shopify (with the exact values
4
4
  * to paste), what to set on the site, and has a button for whatever
5
5
  * RedeemID can do itself: register the webhooks, add the first level.
6
6
  *
7
7
  * Nothing about progress is stored. Each step's state comes from the site
8
- * as it is (setupFacts), so opening Setup again picks up where things stand
9
- * and it always agrees with the Status page.
8
+ * as it is (setupFacts), so opening Connection again picks up where things stand
9
+ * and it always agrees with the Status tab.
10
10
  *
11
11
  * Secrets stay where they are today, in the site's environment: the wizard
12
12
  * checks them and gives the command to set each one, and never stores one.
13
13
  * It writes only what staff can already write (a level, through Access) and
14
14
  * never grants access: levels still come only from Shopify tags.
15
15
  *
16
- * setupSteps() and setupPage() are pure.
16
+ * setupSteps() and setupPanels() are pure.
17
17
  */
18
18
 
19
19
  import { adminConfigured, adminScopes, grantedScopes, topicName, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
@@ -24,7 +24,7 @@ import type { Stores } from "./storage.js";
24
24
  type Toast = { message: string; type: "success" | "error" | "info" };
25
25
  export type Blocks = { blocks: unknown[]; toast?: Toast };
26
26
 
27
- /** Scopes the webhook app needs for staff tools and webhook registration. */
27
+ /** Scopes the Admin API app needs for staff tools and webhook registration. */
28
28
  export const SETUP_SCOPES = ["read_customers", "write_customers"] as const;
29
29
 
30
30
  export const CALLBACK = "/_emdash/api/auth/redeemid/callback";
@@ -134,8 +134,8 @@ export function setupSteps(f: SetupFacts): Step[] {
134
134
  return [
135
135
  { key: "store", title: "Connect the store", state: store ? "done" : "todo" },
136
136
  { key: "sign_in", title: "Create the sign-in client", state: signIn ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
137
- { key: "app", title: "Connect the webhook app", state: app ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
138
- { key: "webhooks", title: "Register the webhooks", state: hooks ? "done" : app ? "todo" : "waiting", waitingFor: "Connect the webhook app first: webhooks are registered through it." },
137
+ { key: "app", title: "Connect the Admin API app", state: app ? "done" : store ? "todo" : "waiting", waitingFor: "Connect the store first." },
138
+ { key: "webhooks", title: "Register the webhooks", state: hooks ? "done" : app ? "todo" : "waiting", waitingFor: "Connect the Admin API app first: webhooks are registered through it." },
139
139
  { key: "level", title: "Create your first level", state: level ? "done" : "todo" },
140
140
  { key: "try", title: "Try a sign-in", state: f.signedIn > 0 ? "done" : ready ? "todo" : "waiting", waitingFor: "Finish the steps above first." },
141
141
  { key: "storefront", title: "Add Storefront tokens (for plans and the shop)", state: f.storefront ? "done" : "optional" },
@@ -182,7 +182,7 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
182
182
  text: "In Shopify admin, open Sales channels, then Headless (add the channel if it isn't there), and Create storefront. Name it after this site. Open its Customer Account API settings, pick a client type (Public needs no secret; Confidential gives one) and, under Application setup, add these:",
183
183
  },
184
184
  {
185
- type: "fields",
185
+ type: "redeemid_copy",
186
186
  fields: [
187
187
  { label: "Callback URI", value: `${f.origin}${CALLBACK}` },
188
188
  { label: "JavaScript origin (public clients)", value: f.origin },
@@ -200,7 +200,7 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
200
200
  ? `Shopify refused the app: ${f.admin} The app must be installed on this store and be in the same Shopify organization.`
201
201
  : f.admin === "ok"
202
202
  ? `The app is connected but lacks ${missingScopes(f).join(" and ")}. Add the scopes, release a new version and let the store accept it.`
203
- : "Shopify signs webhooks with an app's client secret, and RedeemID uses the same app to register webhooks and to resync members.";
203
+ : "RedeemID uses an Admin API app for the staff tools (resyncs, tag edits, member lookups) and to register webhooks, and Shopify signs webhooks with its client secret.";
204
204
  return [
205
205
  ...out,
206
206
  { type: "section", text: why },
@@ -266,34 +266,29 @@ function stepBlocks(step: Step, f: SetupFacts): unknown[] {
266
266
  return out;
267
267
  }
268
268
 
269
- /** The Setup page. Pure. */
270
- export function setupPage(f: SetupFacts, toast?: Toast): Blocks {
271
- const steps = setupSteps(f);
272
- const required = steps.filter((s) => s.state !== "optional" && s.key !== "storefront");
273
- const done = required.filter((s) => s.state === "done").length;
274
- return {
269
+ /** Connection's tab address for each step (/connection/sign-in). */
270
+ export const STEP_TABS: Record<Step["key"], string> = { store: "store", sign_in: "sign-in", app: "app", webhooks: "webhooks", level: "level", try: "try", storefront: "storefront" };
271
+ const SHORT: Record<Step["key"], string> = { store: "Store", sign_in: "Sign-in", app: "Admin app", webhooks: "Webhooks", level: "First level", try: "Try it", storefront: "Storefront" };
272
+
273
+ /** The steps that count toward "set up" (Storefront is optional). */
274
+ export const requiredSteps = (steps: Step[]) => steps.filter((s) => s.key !== "storefront");
275
+
276
+ /**
277
+ * One tab per step, for Connection: the step, with a Check again button
278
+ * until it's done. Labels carry a check mark once done. Pure.
279
+ */
280
+ export function setupPanels(f: SetupFacts): Array<{ tab: string; label: string; blocks: unknown[] }> {
281
+ return setupSteps(f).map((step) => ({
282
+ tab: STEP_TABS[step.key],
283
+ label: step.state === "done" ? `${SHORT[step.key]} ✓` : SHORT[step.key],
275
284
  blocks: [
276
- { type: "header", text: "Setup" },
277
- done === required.length
278
- ? { type: "banner", variant: "default", title: "RedeemID is set up", description: "Members can sign in with their Shopify customer account. Levels, rules and the meter are under Access." }
279
- : {
280
- type: "banner",
281
- variant: "alert",
282
- title: `${done} of ${required.length} steps done`,
283
- description: "Work down the steps. Each one checks the site as it is, so set a secret, redeploy or restart, and press Check again.",
284
- },
285
- { type: "actions", elements: [{ type: "button", action_id: "setup_check", label: "Check again", style: "secondary" }] },
286
- {
287
- type: "table",
288
- page_action_id: "setup_steps",
289
- columns: [
290
- { key: "step", label: "Step" },
291
- { key: "state", label: "State", format: "badge" },
292
- ],
293
- rows: steps.map((s) => ({ step: s.title, state: BADGE[s.state] })),
294
- },
295
- ...steps.flatMap((s) => [{ type: "divider" }, ...stepBlocks(s, f)]),
285
+ ...stepBlocks(step, f),
286
+ ...(step.state === "done" ? [] : [{ type: "actions", elements: [{ type: "button", action_id: "setup_check", value: STEP_TABS[step.key], label: "Check again", style: "secondary" }] }]),
296
287
  ],
297
- ...(toast ? { toast } : {}),
298
- };
288
+ }));
289
+ }
290
+
291
+ /** Where Connection opens: the first step not done, or null once every required one is. Pure. */
292
+ export function nextStep(f: SetupFacts): Step | null {
293
+ return requiredSteps(setupSteps(f)).find((s) => s.state !== "done") ?? null;
299
294
  }
package/src/staff.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { adminConfigured, adminScopes, editTags, registerWebhooks, resync, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
8
8
  import { applyEntryRule, describe, entryRuleFor, levelsFor, membership, passes, requirement, termTree, type Terms } from "./access.js";
9
- import { PROVIDER_ID, type RedeemIDConfig } from "./config.js";
9
+ import { PROVIDER_ID, shopOrigin, type RedeemIDConfig } from "./config.js";
10
10
  import { levelChange } from "./events.js";
11
11
  import { featuresFor } from "./features.js";
12
12
  import { record } from "./log.js";
@@ -32,76 +32,157 @@ function consentLabel(state: string | null | undefined): string {
32
32
  return t.charAt(0).toUpperCase() + t.slice(1);
33
33
  }
34
34
 
35
- export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Blocks["toast"]): Blocks {
35
+ /** The member page's tabs, in order. */
36
+ export const MEMBER_TABS = ["membership", "tags", "account", "privacy"] as const;
37
+ export type MemberTab = (typeof MEMBER_TABS)[number];
38
+
39
+ /** When, to the minute, in UTC: "2026-11-13 18:59 UTC". */
40
+ const stamp = (iso: string | null | undefined) => (iso ? `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`.trim() : "");
41
+
42
+ /**
43
+ * One member: who they are and where they stand at the top, then tabs for
44
+ * their membership, their Shopify tags, their account and privacy.
45
+ */
46
+ export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Blocks["toast"], tab: MemberTab = "membership", exported?: unknown): Blocks {
36
47
  const levels = config.levels ?? [];
37
48
  const { current, past } = membership(m, levels);
38
49
  const features = featuresFor(m, config.features, levels);
39
50
  const featureNames = (config.features ?? []).filter((f) => features.includes(f.slug)).map((f) => f.name);
40
51
  const name = [m.firstName, m.lastName].filter(Boolean).join(" ") || m.email || `Customer ${m.customerId}`;
52
+ let shop: string | null = null;
53
+ try {
54
+ const origin = new URL(shopOrigin(config));
55
+ if (origin.hostname.endsWith(".myshopify.com")) shop = origin.origin;
56
+ } catch {
57
+ shop = null;
58
+ }
59
+ const admin = adminConfigured();
60
+
61
+ const membershipTab = [
62
+ {
63
+ type: "fields",
64
+ fields: [
65
+ { label: "Levels now", value: current.map((l) => `${l.name} (since ${date(l.since)})`).join(", ") || "None" },
66
+ { label: "Features", value: featureNames.join(", ") || "None" },
67
+ { label: "Member since (Shopify)", value: m.dates?.since ?? "" },
68
+ { label: m.dates?.ends ? "Ends on (Shopify)" : "Renews on (Shopify)", value: m.dates?.ends ?? m.dates?.renews ?? "" },
69
+ { label: "Gift until (Shopify)", value: m.dates?.gift ?? "" },
70
+ ],
71
+ },
72
+ { type: "header", text: "Level history" },
73
+ past.length
74
+ ? {
75
+ type: "table",
76
+ page_action_id: "member_levels",
77
+ columns: [
78
+ { key: "level", label: "Level" },
79
+ { key: "from", label: "From" },
80
+ { key: "to", label: "To" },
81
+ ],
82
+ rows: past.map((l) => ({ level: l.name, from: date(l.from), to: date(l.to) })),
83
+ }
84
+ : { type: "context", text: "No earlier levels." },
85
+ ];
86
+
87
+ const tagsTab = [
88
+ { type: "context", text: "Levels come only from these tags. Edits are written to Shopify, then read back, so Shopify stays the only source of access." },
89
+ ...(admin
90
+ ? [
91
+ {
92
+ type: "redeemid_modal",
93
+ block_id: `modal:tags:${m.customerId}`,
94
+ title: "Add tags in Shopify",
95
+ description: "Adds the tags to the customer in Shopify, then reads the customer back. Access follows.",
96
+ trigger: { label: "Add tags", style: "primary" },
97
+ blocks: [
98
+ {
99
+ type: "form",
100
+ block_id: `tags:${m.customerId}`,
101
+ fields: [{ type: "text_input", action_id: "tags", label: "Tags (comma separated)", placeholder: "gold-member" }],
102
+ submit: { label: "Add in Shopify", action_id: "member_tag_add" },
103
+ },
104
+ ],
105
+ },
106
+ ]
107
+ : [{ type: "context", text: "Set the Admin API app's client ID to edit tags and resync from here." }]),
108
+ m.tags.length
109
+ ? {
110
+ type: "table",
111
+ page_action_id: "member_tags",
112
+ columns: [
113
+ { key: "tag", label: "Tag", format: "code" },
114
+ { key: "since", label: "Since" },
115
+ { key: "remove", label: "", format: "element" },
116
+ ],
117
+ rows: m.tags.map((t) => ({
118
+ tag: t,
119
+ since: date(m.tagsSince?.[t.toLowerCase()]),
120
+ remove: admin
121
+ ? {
122
+ type: "button",
123
+ action_id: "member_tag_remove",
124
+ label: "Remove",
125
+ style: "secondary",
126
+ value: { customerId: m.customerId, tag: t },
127
+ confirm: { title: "Remove this tag in Shopify?", text: `Removes "${t}" from the customer in Shopify. Access follows.`, confirm: "Remove", deny: "Cancel", style: "danger" },
128
+ }
129
+ : null,
130
+ })),
131
+ }
132
+ : { type: "empty", title: "No tags", description: "This customer has no tags in Shopify, so no level.", size: "sm" },
133
+ ];
134
+
135
+ const accountTab = [
136
+ {
137
+ type: "fields",
138
+ fields: [
139
+ { label: "Email", value: m.email ?? "" },
140
+ { label: "Shopify customer ID", value: m.customerId },
141
+ { label: "Account", value: m.status === "enabled" ? "On" : "Turned off in Shopify" },
142
+ { label: "Email verified", value: m.emailVerified === undefined ? "" : m.emailVerified ? "Yes" : "No" },
143
+ { label: "Email marketing", value: consentLabel(m.emailMarketing) },
144
+ { label: "SMS marketing", value: consentLabel(m.smsMarketing) },
145
+ { label: "Last synced", value: stamp(m.syncedAt) },
146
+ { label: "Synced by", value: m.source || "unknown" },
147
+ ],
148
+ },
149
+ { type: "divider" },
150
+ ...linkSection(m),
151
+ ];
152
+
153
+ const privacyTab = [...privacySection(m.customerId), ...(exported === undefined ? [] : [{ type: "header", text: "Export" }, { type: "code", language: "jsonc", code: JSON.stringify(exported, null, 2) }])];
154
+
41
155
  return {
42
156
  toast,
43
157
  blocks: [
44
- { type: "actions", elements: [{ type: "button", action_id: "members_back", label: "← All members", style: "secondary" }] },
158
+ {
159
+ type: "actions",
160
+ elements: [
161
+ { type: "button", action_id: "members_back", label: "\u2190 All members", style: "secondary" },
162
+ ...(admin ? [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] : []),
163
+ ...(shop ? [{ type: "link", label: "Open in Shopify", target: { kind: "external", url: `${shop}/admin/customers/${m.customerId}` }, appearance: "secondary" }] : []),
164
+ ],
165
+ },
45
166
  { type: "header", text: name },
167
+ { type: "context", text: [m.email, `Shopify customer ${m.customerId}`, m.userId ? `signed in here since ${date(m.linkedAt)}` : "hasn't signed in here yet"].filter(Boolean).join(" \u00b7 ") },
46
168
  {
47
- type: "fields",
48
- fields: [
49
- { label: "Email", value: m.email ?? "—" },
50
- { label: "Shopify customer", value: m.customerId },
51
- { label: "Account", value: m.status === "enabled" ? "On" : "Turned off in Shopify" },
52
- { label: "Signed in here", value: m.userId ? `Yes, first on ${date(m.linkedAt)}` : "Not yet" },
53
- { label: "Levels", value: current.map((l) => `${l.name} (since ${date(l.since)})`).join(", ") || "—" },
54
- { label: "Levels before", value: past.map((l) => `${l.name} (${date(l.from)} to ${date(l.to)})`).join(", ") || "—" },
55
- { label: "Features", value: featureNames.join(", ") || "—" },
56
- { label: "Member since (Shopify)", value: m.dates?.since ?? "—" },
57
- { label: m.dates?.ends ? "Ends on (Shopify)" : "Renews on (Shopify)", value: m.dates?.ends ?? m.dates?.renews ?? "—" },
58
- { label: "Gift until (Shopify)", value: m.dates?.gift ?? "—" },
59
- { label: "Email verified", value: m.emailVerified === undefined ? "—" : m.emailVerified ? "Yes" : "No" },
60
- { label: "Email marketing", value: consentLabel(m.emailMarketing) },
61
- { label: "SMS marketing", value: consentLabel(m.smsMarketing) },
62
- { label: "Last synced", value: `${m.syncedAt} (${m.source || "unknown"})` },
169
+ type: "stats",
170
+ items: [
171
+ { label: "Account", value: m.status === "enabled" ? "On" : "Off" },
172
+ { label: "Levels", value: current.map((l) => l.name).join(", ") || "None" },
173
+ { label: "Tags", value: String(m.tags.length) },
174
+ ],
175
+ },
176
+ {
177
+ type: "tab",
178
+ default_tab: MEMBER_TABS.indexOf(tab),
179
+ panels: [
180
+ { label: "Membership", blocks: membershipTab },
181
+ { label: "Tags", blocks: tagsTab },
182
+ { label: "Account", blocks: accountTab },
183
+ { label: "Privacy", blocks: privacyTab },
63
184
  ],
64
185
  },
65
- { type: "header", text: "Shopify tags" },
66
- m.tags.length
67
- ? {
68
- type: "table",
69
- page_action_id: "member_tags",
70
- columns: [
71
- { key: "tag", label: "Tag" },
72
- { key: "since", label: "Since" },
73
- { key: "remove", label: "", format: "element" },
74
- ],
75
- rows: m.tags.map((t) => ({
76
- tag: t,
77
- since: date(m.tagsSince?.[t.toLowerCase()]),
78
- remove: adminConfigured()
79
- ? {
80
- type: "button",
81
- action_id: "member_tag_remove",
82
- label: "Remove in Shopify",
83
- style: "danger",
84
- value: { customerId: m.customerId, tag: t },
85
- confirm: { title: "Remove this tag in Shopify?", text: `Removes "${t}" from the customer in Shopify. Access follows.`, confirm: "Remove", deny: "Cancel", style: "danger" },
86
- }
87
- : null,
88
- })),
89
- }
90
- : { type: "context", text: "No tags." },
91
- adminConfigured()
92
- ? {
93
- type: "form",
94
- block_id: `tags:${m.customerId}`,
95
- fields: [{ type: "text_input", action_id: "tags", label: "Add tags in Shopify (comma separated)", placeholder: "gold-member" }],
96
- submit: { label: "Add in Shopify", action_id: "member_tag_add" },
97
- }
98
- : { type: "context", text: "Set the Admin API app's client ID to edit tags and resync from here." },
99
- adminConfigured()
100
- ? { type: "actions", elements: [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] }
101
- : { type: "divider" },
102
- { type: "context", text: "Tags are written to Shopify, then read back. Shopify stays the only source of access." },
103
- ...linkSection(m),
104
- ...privacySection(m.customerId),
105
186
  ],
106
187
  };
107
188
  }
@@ -228,17 +309,18 @@ function privacySection(customerId: string): unknown[] {
228
309
  ]
229
310
  : [];
230
311
  return [
231
- { type: "header", text: "Privacy" },
232
- { type: "actions", elements: [{ type: "button", action_id: "member_export", label: "Export data", style: "secondary", value: customerId }, erase, ...shopify] },
233
312
  { type: "context", text: "Export shows everything RedeemID keeps about this member. Erasing removes it from the site; Shopify stays the record of the customer." },
313
+ { type: "actions", elements: [{ type: "button", action_id: "member_export", label: "Export data", style: "secondary", value: customerId }, erase] },
314
+ ...(shopify.length
315
+ ? [
316
+ { type: "header", text: "In Shopify" },
317
+ { type: "context", text: "Shopify erases the customer's personal data across the store, orders included, after its waiting period. Cancel stops a request that hasn't run yet." },
318
+ { type: "actions", elements: shopify },
319
+ ]
320
+ : []),
234
321
  ];
235
322
  }
236
323
 
237
- /** The member's export, as JSON, under their detail page. */
238
- export function exportBlocks(detail: Blocks, data: unknown): Blocks {
239
- return { ...detail, blocks: [...detail.blocks, { type: "header", text: "Export" }, { type: "code", language: "jsonc", code: JSON.stringify(data, null, 2) }] };
240
- }
241
-
242
324
  /** Every privacy request: what came in, from where, and what's still open. */
243
325
  export async function privacyPage(s: Stores, toast?: Blocks["toast"], extra: unknown[] = []): Promise<Blocks> {
244
326
  const items = await recentRequests(s);
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.30";
1
+ export const VERSION = "0.0.32";