@redeemid/emdash 0.0.22 → 0.0.23

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.
@@ -9,7 +9,7 @@
9
9
  * carries `data-redeemid-level` (the level slugs) for styling, and
10
10
  * `is-locked` with a lock for readers who can't read the members part.
11
11
  */
12
- import { canRead, describe, entryRequirement, viewer, type Terms } from "../access.js";
12
+ import { badgeColor, canRead, describe, entryRequirement, simplify, viewer, type Terms } from "../access.js";
13
13
  import { holdsItem, soldItem, splitAtPaywall } from "../blocks-names.js";
14
14
  import { siteConfig } from "../site-config.js";
15
15
  import { text } from "../text.js";
@@ -37,12 +37,15 @@ const owner = item ? (await viewer(Astro)).member : null;
37
37
  const owned = Boolean(owner && owner.status !== "disabled" && holdsItem(item, owner.tags));
38
38
  const locked = required !== null && !owned && !(await canRead(Astro, { collection, terms, id }, { required })).allowed;
39
39
  const slugs = required?.flat().join(" ") || (required ? "signed-in" : "free");
40
+ // The colour of the level the badge names first, when that level has one.
41
+ const color = badgeColor(config?.levels?.find((l) => l.slug === simplify(required, config?.levels)?.find((c) => c.length)?.[0]));
40
42
  ---
41
43
 
42
44
  {label && (
43
45
  <span
44
46
  class:list={["redeemid-badge", { "is-locked": locked }, className]}
45
47
  data-redeemid-level={slugs}
48
+ style={color ? `--redeemid-level-color: ${color}` : undefined}
46
49
  >
47
50
  {locked && <span class="redeemid-badge__lock" aria-hidden="true">🔒&#xFE0E; </span>}
48
51
  {label}
@@ -3,20 +3,31 @@
3
3
  * Sign in link for visitors; name, levels, Account and Sign out for members.
4
4
  */
5
5
  import { siteConfig } from "../site-config.js";
6
- import { accountUrl, signInUrl, signOutUrl, viewer } from "../access.js";
6
+ import { accountUrl, badgeColor, signInUrl, signOutUrl, topLevels, viewer } from "../access.js";
7
7
  import { text } from "../text.js";
8
8
 
9
9
  const v = await viewer(Astro);
10
10
  const config = await siteConfig(Astro.locals);
11
11
  const here = Astro.url.pathname + Astro.url.search;
12
- const names = (config?.levels ?? []).filter((l) => v.levels.includes(l.slug)).map((l) => l.name);
12
+ // "Gold", not "Gold, Silver", when Gold includes Silver.
13
+ const top = topLevels(v.levels, config?.levels);
14
+ const held = (config?.levels ?? []).filter((l) => top.includes(l.slug));
13
15
  ---
14
16
 
15
17
  <div class="redeemid-account" data-redeemid-signed-in={v.signedIn ? "true" : "false"}>
16
18
  {v.signedIn ? (
17
19
  <>
18
20
  <span class="redeemid-account__who">{v.member?.firstName ?? v.member?.email ?? text(config, "Signed in")}</span>
19
- {names.length > 0 && <span class="redeemid-account__levels">{names.join(", ")}</span>}
21
+ {held.length > 0 && (
22
+ <span class="redeemid-account__levels">
23
+ {held.map((l, i) => (
24
+ <>
25
+ {i > 0 && ", "}
26
+ <span data-redeemid-level={l.slug} style={badgeColor(l) ? `--redeemid-level-color: ${badgeColor(l)}` : undefined}>{l.name}</span>
27
+ </>
28
+ ))}
29
+ </span>
30
+ )}
20
31
  <a class="redeemid-account__link" href={accountUrl(here)}>{text(config, "Account")}</a>
21
32
  <form method="post" action={signOutUrl(here)}>
22
33
  <button type="submit">{text(config, "Sign out")}</button>
@@ -11,7 +11,7 @@
11
11
  * Everything shown comes from the mirrored Shopify customer; Refresh
12
12
  * membership reads it from the store again.
13
13
  */
14
- import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
14
+ import { accountUrl, badgeColor, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
15
15
  import { featuresFor } from "../features.js";
16
16
  import { siteConfig } from "../site-config.js";
17
17
  import { text, textParts } from "../text.js";
@@ -29,6 +29,7 @@ const m = v.member;
29
29
  const disabled = m?.status === "disabled";
30
30
  const { current, past } = m && !disabled ? membership(m, config?.levels) : { current: [], past: [] };
31
31
  const meter = v.signedIn ? await meterUsage(Astro) : null;
32
+ const color = (slug: string) => badgeColor(config?.levels?.find((l) => l.slug === slug));
32
33
  const held = m ? featuresFor(m, config?.features, config?.levels) : [];
33
34
  const features = (config?.features ?? []).filter((f) => held.includes(f.slug));
34
35
  const date = (iso: string | null) => {
@@ -59,8 +60,8 @@ const join = config?.join;
59
60
  <p class="redeemid-member__disabled">{text(config, "Your account is turned off. Contact the store for help.")}</p>
60
61
  ) : current.length > 0 ? (
61
62
  <ul class="redeemid-member__levels">
62
- {current.map((l) => (
63
- <li data-redeemid-level={l.slug}>
63
+ {current.filter((l) => !l.via).map((l) => (
64
+ <li data-redeemid-level={l.slug} style={color(l.slug) ? `--redeemid-level-color: ${color(l.slug)}` : undefined}>
64
65
  <strong>{l.name}</strong>
65
66
  {(dates.since ? day(dates.since) : date(l.since)) && <> {textParts(config, "since {date}", { date: <time datetime={dates.since ?? l.since ?? undefined}>{dates.since ? day(dates.since) : date(l.since)}</time> })}</>}
66
67
  </li>
@@ -6,11 +6,16 @@
6
6
  *
7
7
  * <Plan handle="gold-membership" levels={["gold"]} />
8
8
  *
9
- * With `levels`, members who hold one of them see "You're on this plan"
10
- * in place of the button, and a member who held one before (Shopify removed
11
- * its tag) sees "Renew" instead of "Join".
9
+ * `levels` are the levels the plan gives; left out, they are the levels
10
+ * whose `products` list the handle. Then the card fits the member:
11
+ *
12
+ * - held: they hold one of them through its own tags: "You're on this plan."
13
+ * - included: a level they hold includes it: "Included in your Gold membership."
14
+ * - upgrade: the plan's level includes one they hold: the button says "Upgrade".
15
+ * - renew: they held one before (Shopify removed its tag): "Renew".
16
+ * - join: anyone else.
12
17
  */
13
- import { membership, viewer } from "../access.js";
18
+ import { closure, directLevels, membership, topLevels, viewer } from "../access.js";
14
19
  import { siteConfig } from "../site-config.js";
15
20
  import { money, plan as loadPlan, storeContext, type Plan } from "../storefront.js";
16
21
  import { text } from "../text.js";
@@ -21,15 +26,19 @@ interface Props {
21
26
  label?: string;
22
27
  /** Heading override; defaults to the Shopify product title. */
23
28
  title?: string;
24
- /** The levels this plan gives. Members holding one see `heldLabel`, not the button. */
29
+ /** The levels this plan gives. Defaults to the levels whose `products` list `handle`. */
25
30
  levels?: string[];
26
31
  heldLabel?: string;
27
32
  /** Button text for a member whose plan lapsed. */
28
33
  renewLabel?: string;
34
+ /** Button text for a member this plan would move up. */
35
+ upgradeLabel?: string;
29
36
  }
30
37
 
31
38
  const config = await siteConfig(Astro.locals);
32
- const { handle, label = text(config, "Join"), title, levels = [], heldLabel = text(config, "You're on this plan."), renewLabel = text(config, "Renew") } = Astro.props;
39
+ const { handle, label = text(config, "Join"), title, heldLabel = text(config, "You're on this plan."), renewLabel = text(config, "Renew"), upgradeLabel = text(config, "Upgrade") } = Astro.props;
40
+ const all = config?.levels ?? [];
41
+ const levels = Astro.props.levels?.length ? Astro.props.levels : all.filter((l) => l.products?.some((h) => h.toLowerCase() === handle.toLowerCase())).map((l) => l.slug);
33
42
  let p: Plan | null = null;
34
43
  if (config) {
35
44
  try {
@@ -42,23 +51,34 @@ if (config) {
42
51
  const price = money(p?.price);
43
52
  const here = Astro.url.pathname + Astro.url.search;
44
53
  const v = levels.length > 0 ? await viewer(Astro) : null;
45
- const held = Boolean(v?.levels.some((l) => levels.includes(l)));
54
+ const live = v?.member && v.member.status !== "disabled" ? v.member : null;
55
+ const direct = live ? directLevels(live.tags, all) : [];
56
+ const top = topLevels(v?.levels ?? [], all);
57
+ const held = levels.some((l) => direct.includes(l));
58
+ // Held only through a bigger level: name that one.
59
+ const via = held ? undefined : top.find((t) => levels.some((l) => t !== l && closure(t, all).includes(l)));
60
+ const viaName = via ? (all.find((l) => l.slug === via)?.name ?? via) : null;
61
+ // The plan's level includes one the member holds: it moves them up.
62
+ const upgrade = !held && !via && top.length > 0 && levels.some((l) => top.some((t) => t !== l && closure(l, all).includes(t)));
46
63
  // Lapsed: held one of these levels before. Offered their plan back, not "Join" as if new.
47
- const lapsed = Boolean(!held && v?.member && v.member.status !== "disabled" && membership(v.member, config?.levels).past.some((p) => levels.includes(p.slug)));
64
+ const lapsed = Boolean(!held && !via && live && membership(live, all).past.some((p) => levels.includes(p.slug)));
65
+ const state = held ? "held" : via ? "included" : upgrade ? "upgrade" : lapsed ? "renew" : "join";
48
66
  ---
49
67
 
50
68
  {p ? (
51
- <div class:list={["redeemid-plan", { "is-held": held, "is-lapsed": lapsed }]} data-redeemid-plan={p.handle} data-redeemid-state={held ? "held" : lapsed ? "renew" : "join"}>
69
+ <div class:list={["redeemid-plan", { "is-held": held || Boolean(via), "is-lapsed": lapsed }]} data-redeemid-plan={p.handle} data-redeemid-state={state}>
52
70
  <h2 class="redeemid-plan__title">{title ?? p.title}</h2>
53
71
  {price && <p class="redeemid-plan__price">{price}</p>}
54
72
  {p.description && <p class="redeemid-plan__description">{p.description}</p>}
55
73
  {held ? (
56
74
  <p class="redeemid-plan__held">{heldLabel}</p>
75
+ ) : viaName ? (
76
+ <p class="redeemid-plan__held">{text(config, "Included in your {level} membership.", { level: viaName })}</p>
57
77
  ) : p.availableForSale ? (
58
78
  <form method="post" action="/_emdash/api/auth/redeemid/buy">
59
79
  <input type="hidden" name="plan" value={p.handle} />
60
80
  <input type="hidden" name="return" value={here} />
61
- <button type="submit">{lapsed ? renewLabel : label}</button>
81
+ <button type="submit">{upgrade ? upgradeLabel : lapsed ? renewLabel : label}</button>
62
82
  </form>
63
83
  ) : (
64
84
  <p class="redeemid-plan__unavailable">{text(config, "Not available right now.")}</p>
package/src/config.ts CHANGED
@@ -15,14 +15,65 @@ export interface LevelConfig {
15
15
  slug: string;
16
16
  /** Name shown to members. */
17
17
  name: string;
18
- /** Shopify customer tags that grant this level. Matched case-insensitively. */
19
- tags: string[];
20
- /** "any" (default): one of the tags is enough. "all": every tag is needed. */
18
+ /**
19
+ * Shopify customer tags that grant this level. Matched case-insensitively.
20
+ * This is the level's first tag group; `groups` adds more.
21
+ */
22
+ tags?: string[];
23
+ /** For `tags`: "any" (default): one of the tags is enough. "all": every tag is needed. */
21
24
  match?: "any" | "all";
25
+ /**
26
+ * More tag groups, any one of which also grants the level: a level that is
27
+ * "gold", or "silver" and "annual", is `tags: ["gold"]` with
28
+ * `groups: [{ tags: ["silver", "annual"], match: "all" }]`.
29
+ */
30
+ groups?: TagGroup[];
31
+ /**
32
+ * Slugs of levels this one also opens: Gold with `includes: ["silver"]`
33
+ * reads everything for Silver. Inclusion carries on (Gold includes Silver
34
+ * includes Members), so rules name only the lowest level that may read.
35
+ */
36
+ includes?: string[];
22
37
  /** What the wall says to readers this level would let in, e.g. "Gold members read every investigation." */
23
38
  wall?: string;
24
39
  /** Where the wall's join link goes for this level. Falls back to `join.href`. */
25
40
  joinUrl?: string;
41
+ /**
42
+ * Handles of the Shopify products whose purchase leads to this level
43
+ * (Flow or an app tags the buyer). Display only: plan cards use it to say
44
+ * "You're on this plan", "Included in your Gold membership" or "Upgrade".
45
+ * It grants nothing.
46
+ */
47
+ products?: string[];
48
+ /**
49
+ * Members' home: a same-site path members of this level land on after
50
+ * signing in from the home page or a plain sign-in link, e.g. "/members/gold/".
51
+ * Signing in from an entry returns them to it.
52
+ */
53
+ home?: string;
54
+ /** Badge colour, a hex such as "#c9a227", set as `--redeemid-level-color` on badges and level names. Badges always show the name too. */
55
+ badge?: string;
56
+ }
57
+
58
+ /** One way to hold a level: any one of `tags`, or every one with `match: "all"`. */
59
+ export interface TagGroup {
60
+ tags: string[];
61
+ match?: "any" | "all";
62
+ }
63
+
64
+ /** A level's tag groups: `tags`/`match` first, then `groups`. Empty groups are dropped. */
65
+ export function groupsOf(level: LevelConfig): TagGroup[] {
66
+ const out: TagGroup[] = [];
67
+ if (level.tags?.length) out.push({ tags: level.tags, ...(level.match === "all" ? { match: "all" as const } : {}) });
68
+ for (const g of level.groups ?? []) {
69
+ if (g && Array.isArray(g.tags) && g.tags.length) out.push({ tags: g.tags, ...(g.match === "all" ? { match: "all" as const } : {}) });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** Every tag any of the level's groups names, lower case. */
75
+ export function levelTags(level: LevelConfig): string[] {
76
+ return [...new Set(groupsOf(level).flatMap((g) => g.tags.map((t) => t.toLowerCase())))];
26
77
  }
27
78
 
28
79
  /**
package/src/contract.ts CHANGED
@@ -16,7 +16,7 @@ export { API_VERSION } from "./api-version.js";
16
16
  /** Stable exports, by import path. */
17
17
  export const MODULES = {
18
18
  "@redeemid/emdash": ["redeemid", "redeemidGuard", "redeemidPlugin"],
19
- "@redeemid/emdash/access": ["siteConfig", "viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
19
+ "@redeemid/emdash/access": ["siteConfig", "viewer", "canRead", "readEntry", "entryRequirement", "meterUsage", "membership", "levelsFor", "topLevels", "closure", "simplify", "signInUrl", "signOutUrl", "accountUrl", "cartUrl", "refreshUrl", "safeReturn"],
20
20
  "@redeemid/emdash/features": ["hasFeature", "viewerFeatures", "featuresFor"],
21
21
  "@redeemid/emdash/preferences": ["readPreferences", "savePreferences", "preferenceFields"],
22
22
  "@redeemid/emdash/storefront": ["plan", "product", "collection", "money", "realOptions", "variantFor", "storeContext", "cartDetails", "addToCart", "updateLines", "removeLines", "setDiscountCodes"],
package/src/log.ts CHANGED
@@ -42,6 +42,7 @@ export const LOG_EVENTS: Record<string, string> = {
42
42
  "shopify.admin": "Admin API call",
43
43
  "staff.resync": "Resync",
44
44
  "staff.tags": "Tag edit",
45
+ "staff.link": "Account link",
45
46
  "staff.webhooks": "Webhook registration",
46
47
  "settings.saved": "Settings saved",
47
48
  "events.failed": "Outbound event failed",
package/src/plugin.ts CHANGED
@@ -28,17 +28,17 @@
28
28
  import type { PluginContext, ResolvedPlugin, RouteContext } from "emdash";
29
29
  import { definePlugin } from "emdash";
30
30
 
31
- import { levelsFor, SIGNED_IN, type Terms } from "./access.js";
31
+ import { levelsFor, SIGNED_IN, topLevels, type Terms } from "./access.js";
32
32
  import { accessAction, accessPage, entryPanel, entrySave, entrySlugChanged, isAccessAction, type AccessEnv, type PanelEntry } from "./access-admin.js";
33
33
  import { deleteAccess, entryKey, loadAccess, onLevels, withSaved } from "./settings.js";
34
- import { env, ENV, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
34
+ import { env, ENV, shopOrigin, storeDomain, unknownEnvNames, type LevelConfig, type RedeemIDConfig } from "./config.js";
35
35
  import { discover } from "./shopify.js";
36
36
  import { stores, type MemberRecord, type Stores } from "./storage.js";
37
37
  import { EDITOR_PANELS, PAGES, WIDGETS } from "./descriptor.js";
38
38
  import { VERSION } from "./version.js";
39
39
  import { AdminError, adminConfigured } from "./admin-api.js";
40
40
  import { cancelShopifyErasure, closeRequest, eraseMember, exportMember, exportRequest, requestShopifyErasure } from "./privacy.js";
41
- import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, tagEdit } from "./staff.js";
41
+ import { accessTest, bulkResync, BULK_BATCH, exportBlocks, memberDetail, privacyPage, registerWebhooks, resyncOne, staffLink, staffUnlink, tagEdit } from "./staff.js";
42
42
  import { textProblems } from "./text.js";
43
43
  import { gatedFindings, templateFindings } from "./template-scan.js";
44
44
  import { setupFacts, setupPage, WEBHOOK } from "./setup.js";
@@ -539,6 +539,20 @@ async function action(
539
539
  return m ? memberDetail(config, m, fail(e)) : membersPage(config);
540
540
  }
541
541
  }
542
+ case "member_link":
543
+ case "member_unlink": {
544
+ const id = i.action_id === "member_link" ? String(i.block_id ?? "").replace(/^link:/, "") : String(i.value ?? "");
545
+ try {
546
+ const { getDb } = await import("emdash/runtime");
547
+ const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
548
+ const adapter = createKyselyAdapter((await getDb()) as never);
549
+ const m = i.action_id === "member_link" ? await staffLink(adapter, s, id, i.values?.email) : await staffUnlink(adapter, s, id);
550
+ return memberDetail(config, m, { type: "success", message: m.userId ? "Linked. Their next sign-in uses that site user." : "Unlinked." });
551
+ } catch (e) {
552
+ const m = await s.members.get(id);
553
+ return m ? memberDetail(config, m, fail(e)) : membersPage(config);
554
+ }
555
+ }
542
556
  case "member_tag_add":
543
557
  case "member_tag_remove": {
544
558
  const add = i.action_id === "member_tag_add";
@@ -675,7 +689,7 @@ async function membersPage(config: RedeemIDConfig) {
675
689
  }));
676
690
  const name = (m: MemberRecord) => [m.firstName, m.lastName].filter(Boolean).join(" ") || "No name";
677
691
  const levelNames = (m: MemberRecord) => {
678
- const held = levelsFor(m.tags, levels);
692
+ const held = topLevels(levelsFor(m.tags, levels), levels);
679
693
  return levels.filter((l) => held.includes(l.slug)).map((l) => l.name).join(", ");
680
694
  };
681
695
  return {
@@ -812,6 +826,7 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
812
826
  },
813
827
  ]
814
828
  : []),
829
+ ...customDomainBlocks(config),
815
830
  ...templateBlocks(config),
816
831
  { 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." },
817
832
  { type: "divider" },
@@ -831,6 +846,30 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
831
846
  };
832
847
  }
833
848
 
849
+ /**
850
+ * Status: the Admin API (staff tools, webhook registration, tag reads) only
851
+ * answers on the store's myshopify.com domain, so a custom domain in `shop`
852
+ * breaks it while sign-in still works.
853
+ */
854
+ export function customDomainBlocks(config: RedeemIDConfig): unknown[] {
855
+ if (!adminConfigured() || config.allowInsecureShop) return [];
856
+ let host = "";
857
+ try {
858
+ host = new URL(shopOrigin(config)).hostname;
859
+ } catch {
860
+ return [];
861
+ }
862
+ if (host.endsWith(".myshopify.com")) return [];
863
+ return [
864
+ {
865
+ type: "banner",
866
+ variant: "alert",
867
+ title: "Use the myshopify.com domain",
868
+ description: `The store domain is ${host}. Shopify's Admin API, which the staff tools, webhook registration and tag reads use, only answers on the store's myshopify.com domain. Set REDEEMID_SHOPIFY_STORE_DOMAIN (or shop) to it; sign-in works with it too.`,
869
+ },
870
+ ];
871
+ }
872
+
834
873
  /** Status: templates that would show a gated body in full (see template-scan.ts). */
835
874
  function templateBlocks(config: RedeemIDConfig): unknown[] {
836
875
  const found = templateFindings();
package/src/privacy.ts CHANGED
@@ -82,22 +82,25 @@ export async function eraseMember(config: RedeemIDConfig | null, s: Stores, cust
82
82
  return Boolean(member);
83
83
  }
84
84
 
85
- /** shop/redact: erase every member and meter record. */
85
+ /**
86
+ * shop/redact: erase every member and meter record. Deletes a page of 100 at
87
+ * a time in one statement, so even a large shop stays well inside a Workers
88
+ * Free request's query limit (a few queries per hundred members).
89
+ */
86
90
  export async function eraseAll(config: RedeemIDConfig | null, s: Stores): Promise<number> {
87
91
  let n = 0;
88
92
  for (;;) {
89
93
  const page = await s.members.query({ limit: 100 });
90
94
  if (page.items.length === 0) break;
91
- for (const r of page.items) {
92
- for (const m of await meterFor(s, r.data.userId)) await s.meter.delete(m.id);
93
- await s.members.delete(r.id);
94
- n++;
95
- }
95
+ const deleted = await s.members.deleteMany(page.items.map((r) => r.id));
96
+ n += page.items.length;
97
+ // A store that deletes nothing would loop forever.
98
+ if (deleted === 0) throw new Error("shop/redact: members could not be deleted");
96
99
  }
97
100
  for (;;) {
98
101
  const page = await s.meter.query({ limit: 100 });
99
102
  if (page.items.length === 0) break;
100
- for (const r of page.items) await s.meter.delete(r.id);
103
+ if ((await s.meter.deleteMany(page.items.map((r) => r.id))) === 0) throw new Error("shop/redact: meter records could not be deleted");
101
104
  }
102
105
  await log(s, { kind: "erase", source: "shopify", customerId: null, status: "done", note: `Shop redacted: ${n} members` });
103
106
  return n;
@@ -18,7 +18,7 @@ import { env, ENV, PROVIDER_ID, storeDomain } from "../config.js";
18
18
  import { siteConfig } from "../site-config.js";
19
19
  import { emit, levelChange } from "../events.js";
20
20
  import { record } from "../log.js";
21
- import { levelsFor } from "../access.js";
21
+ import { levelsFor, memberHome } from "../access.js";
22
22
  import { discover, exchangeCode, identity, logoutUrl, ShopifyError, verifyIdToken, type Discovery } from "../shopify.js";
23
23
  import { emptyMember, stampTags, stores } from "../storage.js";
24
24
  import { adminConfigured, resync } from "../admin-api.js";
@@ -149,7 +149,10 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
149
149
  if (sealed) session.set(SESSION_CUSTOMER_TOKEN, sealed);
150
150
  else session.delete?.(SESSION_CUSTOMER_TOKEN);
151
151
  } else session.delete?.(SESSION_CUSTOMER_TOKEN);
152
- return flow.refresh ? fail(redirect, returnTo, "refreshed") : redirect(returnTo);
152
+ if (flow.refresh) return fail(redirect, returnTo, "refreshed");
153
+ // From the home page or a plain sign-in link: the member's home for their level.
154
+ const home = returnTo === "/" && member.status !== "disabled" ? memberHome(who.tags, config.levels) : null;
155
+ return redirect(home ?? returnTo);
153
156
  } catch (e) {
154
157
  const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
155
158
  console.error("[redeemid] sign-in failed:", (e as Error).message);
@@ -16,7 +16,7 @@ import { stores, type Stores } from "../storage.js";
16
16
  import { adminConfigured, resync } from "../admin-api.js";
17
17
  import type { RedeemIDConfig } from "../config.js";
18
18
  import { record } from "../log.js";
19
- import { apply, customerOf, kindOf, verifyHmac, type Outcome } from "../webhooks.js";
19
+ import { apply, customerOf, kindOf, mergeIds, verifyHmac, type Outcome } from "../webhooks.js";
20
20
 
21
21
  export const prerender = false;
22
22
 
@@ -81,6 +81,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
81
81
  try {
82
82
  const config = await siteConfig(locals);
83
83
  const outcome = (await currentTags(config, s, topic, customerId)) ?? (await apply(s, topic, payload, (before, after) => levelChange(config, before, after, topic), config));
84
+ if (outcome === "processed" && kindOf(topic) === "merge") await mergedTags(config, s, topic, payload);
84
85
  if (delivery) await s.deliveries.put(delivery, { topic, receivedAt, outcome, customerId }).catch(() => undefined);
85
86
  await record({ event: "webhook.received", label: topic, status: outcome, origin: "webhook", customerId, ms: Date.now() - started });
86
87
  return new Response(outcome, { status: 200 });
@@ -112,3 +113,22 @@ async function currentTags(config: RedeemIDConfig | null, s: Stores, topic: stri
112
113
  return null;
113
114
  }
114
115
  }
116
+
117
+ /**
118
+ * customers/merge moves the sign-in link to the customer kept, but the payload
119
+ * has no tags. With the Admin API app set up, read the survivor's tags now
120
+ * rather than wait for its next webhook or sign-in. A failed read leaves the
121
+ * merge as it is.
122
+ */
123
+ async function mergedTags(config: RedeemIDConfig | null, s: Stores, topic: string, payload: Record<string, unknown>): Promise<void> {
124
+ const { kept } = mergeIds(payload);
125
+ if (!config || !kept || !adminConfigured()) return;
126
+ try {
127
+ const before = await s.members.get(kept);
128
+ if (!before) return;
129
+ const { updated } = await resync(config, s, [kept], topic);
130
+ if (updated[0]) await levelChange(config, before.tags, updated[0], topic);
131
+ } catch (e) {
132
+ console.error("[redeemid] merge resync failed:", (e as Error).message);
133
+ }
134
+ }
package/src/setup.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  */
18
18
 
19
19
  import { adminConfigured, adminScopes, webhookSubscriptions, WEBHOOK_TOPICS } from "./admin-api.js";
20
- import { env, ENV, storeDomain, type RedeemIDConfig } from "./config.js";
20
+ import { env, ENV, levelTags, storeDomain, type RedeemIDConfig } from "./config.js";
21
21
  import { discover } from "./shopify.js";
22
22
  import type { Stores } from "./storage.js";
23
23
 
@@ -100,7 +100,7 @@ export async function setupFacts(config: RedeemIDConfig, s: Stores, origin: stri
100
100
  admin,
101
101
  scopes,
102
102
  webhooksMissing,
103
- levels: (config.levels ?? []).map((l) => ({ name: l.name, tags: l.tags })),
103
+ levels: (config.levels ?? []).map((l) => ({ name: l.name, tags: levelTags(l) })),
104
104
  signedIn,
105
105
  storefront: Boolean(env(ENV.storefrontPrivateToken) || env(ENV.storefrontPublicToken)),
106
106
  };
package/src/staff.ts CHANGED
@@ -6,12 +6,12 @@
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 type { RedeemIDConfig } from "./config.js";
9
+ import { PROVIDER_ID, 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";
13
13
  import { recentRequests } from "./privacy.js";
14
- import { cleanTags, numericId, type MemberRecord, type Stores } from "./storage.js";
14
+ import { cleanTags, memberForUser, numericId, type MemberRecord, type Stores } from "./storage.js";
15
15
 
16
16
  /** Members resynced per click; well inside Shopify's query cost limits. */
17
17
  /**
@@ -100,11 +100,98 @@ export function memberDetail(config: RedeemIDConfig, m: MemberRecord, toast?: Bl
100
100
  ? { type: "actions", elements: [{ type: "button", action_id: "member_resync", label: "Resync from Shopify", style: "primary", value: m.customerId }] }
101
101
  : { type: "divider" },
102
102
  { type: "context", text: "Tags are written to Shopify, then read back. Shopify stays the only source of access." },
103
+ ...linkSection(m),
103
104
  ...privacySection(m.customerId),
104
105
  ],
105
106
  };
106
107
  }
107
108
 
109
+ /** Linking the customer to a site user whose email differs, on their detail page. */
110
+ function linkSection(m: MemberRecord): unknown[] {
111
+ return [
112
+ { type: "header", text: "Site user" },
113
+ {
114
+ type: "context",
115
+ text: m.userId
116
+ ? "This customer signs in as the site user linked below. Unlink to let their next sign-in pick the user by email again."
117
+ : "Signing in links the customer to the site user with the same verified email. When their emails differ, link them here: their next sign-in uses that user.",
118
+ },
119
+ m.userId
120
+ ? {
121
+ type: "actions",
122
+ elements: [
123
+ {
124
+ type: "button",
125
+ action_id: "member_unlink",
126
+ label: "Unlink site user",
127
+ style: "danger",
128
+ value: m.customerId,
129
+ confirm: { title: "Unlink this site user?", text: "Their levels stop applying to that user until a sign-in links a user again. Nothing changes in Shopify.", confirm: "Unlink", deny: "Cancel", style: "danger" },
130
+ },
131
+ ],
132
+ }
133
+ : {
134
+ type: "form",
135
+ block_id: `link:${m.customerId}`,
136
+ fields: [{ type: "text_input", action_id: "email", label: "Site user's email", placeholder: "reader@example.com" }],
137
+ submit: { label: "Link", action_id: "member_link" },
138
+ },
139
+ ];
140
+ }
141
+
142
+ /** The parts of EmDash's auth adapter that linking uses. */
143
+ export interface LinkAdapter {
144
+ getUserByEmail(email: string): Promise<{ id: string; role: number; disabled: boolean } | null>;
145
+ getOAuthAccount(provider: string, providerAccountId: string): Promise<{ userId: string } | null>;
146
+ getOAuthAccountsByUserId(userId: string): Promise<Array<{ provider: string; providerAccountId: string }>>;
147
+ createOAuthAccount(account: { provider: string; providerAccountId: string; userId: string }): Promise<unknown>;
148
+ deleteOAuthAccount(provider: string, providerAccountId: string): Promise<void>;
149
+ }
150
+
151
+ /** EmDash's Subscriber role: anything above it is staff. */
152
+ const SUBSCRIBER = 10;
153
+
154
+ export class LinkError extends Error {}
155
+
156
+ /**
157
+ * Link a customer to an existing site user by the user's email, for a member
158
+ * whose store email differs. Only Subscribers, never staff, and never a user
159
+ * already linked to another customer. Access still comes only from the
160
+ * customer's Shopify tags.
161
+ */
162
+ export async function staffLink(adapter: LinkAdapter, s: Stores, customerId: string, rawEmail: unknown): Promise<MemberRecord> {
163
+ const m = await s.members.get(customerId);
164
+ if (!m) throw new LinkError("That member is gone.");
165
+ if (m.userId) throw new LinkError("This customer is already linked. Unlink first.");
166
+ const email = String(rawEmail ?? "").trim().toLowerCase();
167
+ if (!email.includes("@")) throw new LinkError("Give the site user's email.");
168
+ const user = await adapter.getUserByEmail(email);
169
+ if (!user) throw new LinkError("No site user has that email.");
170
+ if (user.role > SUBSCRIBER) throw new LinkError("Staff accounts can't be linked to a store customer.");
171
+ if (user.disabled) throw new LinkError("That site user is turned off.");
172
+ const theirs = (await adapter.getOAuthAccountsByUserId(user.id)).find((a) => a.provider === PROVIDER_ID && a.providerAccountId !== customerId);
173
+ const other = theirs ? null : await memberForUser(s, user.id);
174
+ if (theirs || (other && other.customerId !== customerId)) throw new LinkError(`That site user is linked to customer ${theirs?.providerAccountId ?? other?.customerId}.`);
175
+ const held = await adapter.getOAuthAccount(PROVIDER_ID, customerId);
176
+ if (held && held.userId !== user.id) await adapter.deleteOAuthAccount(PROVIDER_ID, customerId);
177
+ if (!held || held.userId !== user.id) await adapter.createOAuthAccount({ provider: PROVIDER_ID, providerAccountId: customerId, userId: user.id });
178
+ const linked = { ...m, userId: user.id, linkedAt: m.linkedAt ?? new Date().toISOString() };
179
+ await s.members.put(customerId, linked);
180
+ await record({ event: "staff.link", label: "linked", status: "ok", origin: "staff", customerId });
181
+ return linked;
182
+ }
183
+
184
+ /** Undo a link: the customer's next sign-in picks a user by email again. */
185
+ export async function staffUnlink(adapter: LinkAdapter, s: Stores, customerId: string): Promise<MemberRecord> {
186
+ const m = await s.members.get(customerId);
187
+ if (!m) throw new LinkError("That member is gone.");
188
+ if (await adapter.getOAuthAccount(PROVIDER_ID, customerId)) await adapter.deleteOAuthAccount(PROVIDER_ID, customerId);
189
+ const unlinked = { ...m, userId: null };
190
+ await s.members.put(customerId, unlinked);
191
+ await record({ event: "staff.link", label: "unlinked", status: "ok", origin: "staff", customerId });
192
+ return unlinked;
193
+ }
194
+
108
195
  /** Export and erase for one member, on their detail page. */
109
196
  function privacySection(customerId: string): unknown[] {
110
197
  const erase = {
package/src/text.ts CHANGED
@@ -118,7 +118,7 @@ export const STRINGS = {
118
118
  "I understand",
119
119
  "Delete my data",
120
120
  ],
121
- plan: ["Join", "Renew", "You're on this plan.", "Not available right now."],
121
+ plan: ["Join", "Renew", "Upgrade", "You're on this plan.", "Included in your {level} membership.", "Not available right now."],
122
122
  preferences: ["Your preferences", "Save preferences", "Your preferences can't be loaded right now. Try again in a few minutes.", "Not set", "Yes", "No", "One per line."],
123
123
  product: [
124
124
  "Add to cart",
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.22";
1
+ export const VERSION = "0.0.23";
package/src/webhooks.ts CHANGED
@@ -10,6 +10,12 @@ import { cleanTags, emptyMember, numericId, stampTags, type MemberRecord, type S
10
10
  import type { RedeemIDConfig } from "./config.js";
11
11
  import { eraseAll, eraseMember, shopifyDataRequest } from "./privacy.js";
12
12
 
13
+ /** customers/merge: the customer kept and the one merged away, or nulls for a failed merge. */
14
+ export function mergeIds(payload: Record<string, unknown>): { kept: string | null; gone: string | null } {
15
+ if (payload.status === "failed") return { kept: null, gone: null };
16
+ return { kept: numericId(payload.admin_graphql_api_customer_kept_id), gone: numericId(payload.admin_graphql_api_customer_deleted_id) };
17
+ }
18
+
13
19
  export type Kind = "profile" | "delete" | "status" | "tags" | "merge" | "compliance" | "ignored";
14
20
 
15
21
  const TOPICS: Record<string, Kind> = {
@@ -124,9 +130,7 @@ export async function apply(
124
130
  }
125
131
 
126
132
  if (kind === "merge") {
127
- if (payload.status === "failed") return "skipped";
128
- const kept = numericId(payload.admin_graphql_api_customer_kept_id);
129
- const gone = numericId(payload.admin_graphql_api_customer_deleted_id);
133
+ const { kept, gone } = mergeIds(payload);
130
134
  if (!kept || !gone) return "skipped";
131
135
  const old = await s.members.get(gone);
132
136
  if (old?.userId) {