@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.
- package/README.md +36 -12
- package/docs/developer-api.md +5 -3
- package/package.json +1 -1
- package/src/access-admin.ts +83 -12
- package/src/access.ts +128 -28
- package/src/agents.ts +3 -3
- package/src/api-version.ts +1 -1
- package/src/components/AccessBadge.astro +4 -1
- package/src/components/Account.astro +14 -3
- 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/log.ts +1 -0
- package/src/plugin.ts +43 -4
- package/src/privacy.ts +10 -7
- package/src/routes/callback.ts +5 -2
- package/src/routes/webhook.ts +21 -1
- package/src/setup.ts +2 -2
- package/src/staff.ts +89 -2
- package/src/text.ts +1 -1
- package/src/version.ts +1 -1
- package/src/webhooks.ts +7 -3
|
@@ -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">🔒︎ </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
|
-
|
|
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
|
-
{
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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.
|
|
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,
|
|
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
|
|
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 &&
|
|
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={
|
|
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
|
-
/**
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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;
|
package/src/routes/callback.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/routes/webhook.ts
CHANGED
|
@@ -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
|
|
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 = {
|
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.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
|
-
|
|
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) {
|