@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/README.md +38 -14
- package/docs/developer-api.md +5 -3
- package/package.json +1 -1
- package/src/access-admin.ts +84 -13
- package/src/access.ts +129 -29
- package/src/agents.ts +3 -3
- package/src/api-version.ts +1 -1
- package/src/blocks-names.ts +6 -8
- package/src/components/AccessBadge.astro +8 -9
- package/src/components/Account.astro +14 -3
- package/src/components/Content.astro +6 -6
- package/src/components/MemberCard.astro +4 -3
- package/src/components/Plan.astro +30 -10
- package/src/config.ts +54 -3
- package/src/contract.ts +1 -1
- package/src/files.ts +4 -5
- package/src/log.ts +1 -0
- package/src/plugin.ts +48 -17
- package/src/privacy.ts +10 -7
- package/src/routes/callback.ts +5 -2
- package/src/routes/webhook.ts +21 -1
- package/src/settings.ts +1 -1
- package/src/setup.ts +2 -2
- package/src/staff.ts +90 -3
- package/src/text.ts +1 -1
- package/src/version.ts +1 -1
- package/src/webhooks.ts +7 -3
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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) {
|