@redeemid/emdash 0.0.22 → 0.0.24

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/settings.ts CHANGED
@@ -226,7 +226,7 @@ type LevelListener = (levels: LevelConfig[]) => void;
226
226
  const LISTENERS = Symbol.for("redeemid:level-listeners");
227
227
 
228
228
  /**
229
- * The editor's level pickers (on the Paywall break and other blocks) are
229
+ * The editor's level pickers (on the Members only block and others) are
230
230
  * declared when the plugin starts. The plugin registers a listener here so
231
231
  * they follow levels saved in the admin.
232
232
  */
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 = {
@@ -311,7 +398,7 @@ export async function accessTest(config: RedeemIDConfig, values: Record<string,
311
398
  type: "banner",
312
399
  variant: ok ? "default" : "alert",
313
400
  title: ok ? "This reader can read it" : "This reader is kept out",
314
- description: "A Paywall break with levels picked, a Members only part, drip and the meter can still change what a page shows.",
401
+ description: "A Members only part with levels picked, drip and the meter can still change what a page shows.",
315
402
  },
316
403
  );
317
404
  }
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.24";
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) {