@redeemid/emdash 0.0.8 → 0.0.10

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.
Files changed (41) hide show
  1. package/README.md +127 -2
  2. package/docs/shopify-setup.md +5 -1
  3. package/package.json +5 -1
  4. package/src/access.ts +9 -3
  5. package/src/admin-api.ts +191 -0
  6. package/src/blocks/ContributionBlock.astro +1 -1
  7. package/src/blocks/PlanBlock.astro +1 -1
  8. package/src/blocks/PreferencesBlock.astro +12 -0
  9. package/src/blocks/index.ts +2 -0
  10. package/src/blocks-names.ts +1 -0
  11. package/src/components/AccessBadge.astro +4 -3
  12. package/src/components/Account.astro +5 -4
  13. package/src/components/Cart.astro +4 -1
  14. package/src/components/CartView.astro +14 -13
  15. package/src/components/Collection.astro +2 -1
  16. package/src/components/Content.astro +10 -4
  17. package/src/components/Contribution.astro +4 -3
  18. package/src/components/Feature.astro +20 -0
  19. package/src/components/LockedPanel.astro +10 -8
  20. package/src/components/MemberCard.astro +22 -13
  21. package/src/components/Notices.astro +10 -2
  22. package/src/components/Plan.astro +3 -2
  23. package/src/components/Preferences.astro +102 -0
  24. package/src/components/Product.astro +6 -5
  25. package/src/config.ts +53 -0
  26. package/src/descriptor.ts +1 -0
  27. package/src/events.ts +75 -0
  28. package/src/features.ts +39 -0
  29. package/src/files.ts +191 -0
  30. package/src/guard.ts +43 -2
  31. package/src/index.ts +1 -0
  32. package/src/plugin.ts +142 -3
  33. package/src/preferences.ts +298 -0
  34. package/src/routes/callback.ts +8 -1
  35. package/src/routes/preferences.ts +82 -0
  36. package/src/routes/webhook.ts +5 -3
  37. package/src/staff.ts +220 -0
  38. package/src/storage.ts +3 -1
  39. package/src/text.ts +174 -0
  40. package/src/version.ts +1 -1
  41. package/src/webhooks.ts +11 -2
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Features: named perks a member holds through their levels or their
3
+ * Shopify tags (an ad-free site, a supporter thank-you, early access).
4
+ * Like levels they come only from the mirrored tags; nothing here grants
5
+ * them otherwise.
6
+ *
7
+ * features: [
8
+ * { slug: "ad-free", name: "Ad-free", levels: ["gold", "premium"] },
9
+ * { slug: "supporter", name: "Supporter", tags: ["supporter"] },
10
+ * ]
11
+ */
12
+
13
+ import { levelsFor, viewer, type AstroLike } from "./access.js";
14
+ import type { FeatureConfig, LevelConfig } from "./config.js";
15
+ import { siteConfig } from "./site-config.js";
16
+ import type { MemberRecord } from "./storage.js";
17
+
18
+ /** The feature slugs a member holds: from any of the feature's levels or tags. */
19
+ export function featuresFor(member: Pick<MemberRecord, "tags" | "status"> | null, features: FeatureConfig[] = [], levels: LevelConfig[] = []): string[] {
20
+ if (!member || member.status === "disabled") return [];
21
+ const held = levelsFor(member.tags, levels);
22
+ const tags = new Set(member.tags.map((t) => t.toLowerCase()));
23
+ return features
24
+ .filter((f) => (f.levels ?? []).some((l) => held.includes(l)) || (f.tags ?? []).some((t) => tags.has(t.toLowerCase())))
25
+ .map((f) => f.slug);
26
+ }
27
+
28
+ /** Does the visitor hold this feature? */
29
+ export async function hasFeature(astro: AstroLike, slug: string): Promise<boolean> {
30
+ return (await viewerFeatures(astro)).includes(slug);
31
+ }
32
+
33
+ /** The visitor's features. */
34
+ export async function viewerFeatures(astro: AstroLike): Promise<string[]> {
35
+ const config = siteConfig((astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
36
+ if (!config?.features?.length) return [];
37
+ const v = await viewer(astro);
38
+ return featuresFor(v.member, config.features, config.levels);
39
+ }
package/src/files.ts ADDED
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Protected files: uploads in a media folder named in `files` are served only
3
+ * to readers the folder's rule lets in, checked on every download. EmDash
4
+ * serves uploads at /_emdash/api/media/file/<storage key>; the guard
5
+ * middleware calls fileAccess() before EmDash answers, refuses readers
6
+ * without access, and marks allowed downloads private so no cache keeps them.
7
+ *
8
+ * files: [
9
+ * { folder: "Gold downloads", levels: ["gold"] },
10
+ * { folder: "Member downloads", levels: [] }, // any signed-in member
11
+ * { folder: "Supporter extras", features: ["supporter"] },
12
+ * { folder: "Story files", entries: true }, // whoever can read an entry using it
13
+ * ]
14
+ *
15
+ * Staff (any EmDash role above Subscriber) can always download. Folder names
16
+ * match case-insensitively. Access still comes only from Shopify tags.
17
+ */
18
+
19
+ import { readEntry, viewer, type AstroLike, type Terms } from "./access.js";
20
+ import { PAYWALL_BLOCK, sections, splitAtPaywall } from "./blocks-names.js";
21
+ import type { FileRule, RedeemIDConfig } from "./config.js";
22
+ import { featuresFor } from "./features.js";
23
+ import { text } from "./text.js";
24
+
25
+ export const FILE_PATH = "/_emdash/api/media/file/";
26
+ const SUBSCRIBER = 10;
27
+
28
+ export type FileVerdict = { protected: false } | { protected: true; allowed: true } | { protected: true; allowed: false; reason: "sign_in" | "denied" };
29
+
30
+ interface MediaRow {
31
+ id: string;
32
+ folder: string | null;
33
+ }
34
+
35
+ /** The media item behind a storage key, with its folder's name. */
36
+ async function mediaByKey(db: unknown, key: string): Promise<MediaRow | null> {
37
+ // Kysely, typed loosely: EmDash's schema types aren't exported.
38
+ const k = db as {
39
+ selectFrom(t: string): {
40
+ leftJoin(t: string, a: string, b: string): { select(c: string[]): { where(c: string, op: string, v: string): { executeTakeFirst(): Promise<Record<string, unknown> | undefined> } } };
41
+ };
42
+ };
43
+ const row = await k
44
+ .selectFrom("media")
45
+ .leftJoin("media_folders", "media_folders.id", "media.folder_id")
46
+ .select(["media.id as id", "media_folders.name as folder"])
47
+ .where("media.storage_key", "=", key)
48
+ .executeTakeFirst();
49
+ return row ? { id: String(row.id), folder: (row.folder as string | null) ?? null } : null;
50
+ }
51
+
52
+ /** Published entries that use the media item: collection and id. */
53
+ async function entriesUsing(db: unknown, mediaId: string): Promise<Array<{ collection: string; id: string }>> {
54
+ const k = db as {
55
+ selectFrom(t: string): {
56
+ innerJoin(t: string, cb: (j: { onRef(a: string, op: string, b: string): { onRef(a: string, op: string, b: string): unknown } }) => unknown): {
57
+ select(c: string[]): {
58
+ where(c: string, op: string, v: unknown): {
59
+ where(c: string, op: string, v: unknown): { where(c: string, op: string, v: unknown): { limit(n: number): { execute(): Promise<Array<Record<string, unknown>>> } } };
60
+ };
61
+ };
62
+ };
63
+ };
64
+ };
65
+ const rows = await k
66
+ .selectFrom("_emdash_media_usage_sources as s")
67
+ .innerJoin("_emdash_media_usage as u", (j) => j.onRef("u.source_key", "=", "s.source_key").onRef("u.generation", "=", "s.current_generation"))
68
+ .select(["s.collection_slug as collection", "s.content_id as id"])
69
+ .where("u.media_id", "=", mediaId)
70
+ .where("s.content_status", "=", "published")
71
+ .where("s.content_deleted_at", "is", null)
72
+ .limit(50)
73
+ .execute();
74
+ const seen = new Set<string>();
75
+ return rows
76
+ .filter((r) => typeof r.collection === "string" && typeof r.id === "string")
77
+ .map((r) => ({ collection: r.collection as string, id: r.id as string }))
78
+ .filter((e) => !seen.has(`${e.collection}:${e.id}`) && Boolean(seen.add(`${e.collection}:${e.id}`)));
79
+ }
80
+
81
+ /** The levels picked on an entry body's breaks and members-only parts: readers must hold one from each. */
82
+ function bodyClauses(body: unknown): string[][] {
83
+ const blocks = Array.isArray(body) ? body : [];
84
+ const out: string[][] = [];
85
+ const split = splitAtPaywall(blocks);
86
+ if (split.paywall && split.levels.length) out.push(split.levels);
87
+ for (const s of sections(blocks)) if (s.members && s.levels.length) out.push(s.levels);
88
+ return out;
89
+ }
90
+
91
+ /** May this reader read the whole entry: its rule, drip, and every part with levels picked? */
92
+ async function readsWhole(astro: AstroLike, entry: { collection: string; id: string }): Promise<boolean> {
93
+ const { getEmDashEntry } = await import("emdash");
94
+ const result = (await getEmDashEntry(entry.collection, entry.id)) as { entry?: { data?: Record<string, unknown> } | null } | null;
95
+ const data = result?.entry?.data;
96
+ if (!data) return false;
97
+ const terms = data.terms as Terms | undefined;
98
+ const verdict = await readEntry(astro, { collection: entry.collection, terms });
99
+ if (!verdict.allowed) return false;
100
+ const body = Object.values(data).find((v) => Array.isArray(v) && v.some((b) => (b as { _type?: string })?._type === PAYWALL_BLOCK || (b as { _type?: string })?._type === "redeemid-members"));
101
+ const clauses = bodyClauses(body);
102
+ return clauses.every((clause) => clause.some((l) => verdict.viewer.levels.includes(l) || (l === "signed-in" && verdict.viewer.signedIn)));
103
+ }
104
+
105
+ export function ruleFor(config: RedeemIDConfig | null, folder: string | null): FileRule | null {
106
+ if (!folder || !config?.files?.length) return null;
107
+ const name = folder.trim().toLowerCase();
108
+ return config.files.find((r) => r.folder.trim().toLowerCase() === name) ?? null;
109
+ }
110
+
111
+ /** Decide one download. `key` is the storage key from the file URL. */
112
+ export async function fileAccess(astro: AstroLike, config: RedeemIDConfig | null, key: string): Promise<FileVerdict> {
113
+ if (!config?.files?.length) return { protected: false };
114
+ const locals = astro.locals as { user?: { role?: number } | null; emdash?: { db?: unknown } | null };
115
+ const db = locals.emdash?.db;
116
+ if (!db) return { protected: false };
117
+ const media = await mediaByKey(db, key);
118
+ const rule = ruleFor(config, media?.folder ?? null);
119
+ if (!media || !rule) return { protected: false };
120
+
121
+ const role = locals.user?.role ?? 0;
122
+ if (role > SUBSCRIBER) return { protected: true, allowed: true };
123
+ const v = await viewer(astro);
124
+ if (!v.signedIn) return { protected: true, allowed: false, reason: "sign_in" };
125
+ const m = v.member;
126
+ if (!m || m.status === "disabled") return { protected: true, allowed: false, reason: "denied" };
127
+
128
+ if (rule.levels && (rule.levels.length === 0 ? true : rule.levels.some((l) => v.levels.includes(l) || l === "signed-in"))) {
129
+ return { protected: true, allowed: true };
130
+ }
131
+ if (rule.features?.length && featuresFor(m, config.features, config.levels).some((f) => rule.features?.includes(f))) {
132
+ return { protected: true, allowed: true };
133
+ }
134
+ if (rule.entries) {
135
+ for (const e of await entriesUsing(db, media.id)) {
136
+ if (await readsWhole(astro, e)) return { protected: true, allowed: true };
137
+ }
138
+ }
139
+ return { protected: true, allowed: false, reason: "denied" };
140
+ }
141
+
142
+ /**
143
+ * Every storage key a file URL path could name: the key after each layer of
144
+ * percent-decoding, because storage backends differ in whether they decode
145
+ * keys. The guard checks all of them, so an encoded key can't slip past.
146
+ */
147
+ export function keysFromPath(pathname: string): string[] | null {
148
+ if (!pathname.startsWith(FILE_PATH)) return null;
149
+ let candidate = pathname.slice(FILE_PATH.length);
150
+ if (!candidate) return null;
151
+ const out: string[] = [];
152
+ for (let layer = 0; layer < 4; layer++) {
153
+ let decoded: string;
154
+ try {
155
+ decoded = decodeURIComponent(candidate);
156
+ } catch {
157
+ break;
158
+ }
159
+ out.push(decoded);
160
+ if (decoded === candidate) break;
161
+ candidate = decoded;
162
+ }
163
+ return [...new Set(out)];
164
+ }
165
+
166
+ /** Decide a download that may name several keys: the first protected one decides. */
167
+ export async function pathAccess(astro: AstroLike, config: RedeemIDConfig | null, keys: string[]): Promise<FileVerdict> {
168
+ for (const key of keys) {
169
+ const v = await fileAccess(astro, config, key);
170
+ if (v.protected) return v;
171
+ }
172
+ return { protected: false };
173
+ }
174
+
175
+ function escapeHtml(s: string): string {
176
+ return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
177
+ }
178
+
179
+ /** The page a refused download gets. */
180
+ export function refusal(reason: "sign_in" | "denied", signIn: string, config: RedeemIDConfig | null = null): Response {
181
+ const status = reason === "sign_in" ? 401 : 403;
182
+ const t = (s: string) => escapeHtml(text(config, s));
183
+ const body =
184
+ reason === "sign_in"
185
+ ? `<p>${t("Sign in to download this file.")}</p><p><a href="${escapeHtml(signIn)}">${t("Sign in")}</a></p>`
186
+ : `<p>${t("Your membership doesn't include this file.")}</p>`;
187
+ return new Response(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="robots" content="noindex"><title>${t("File not available")}</title></head><body><main>${body}</main></body></html>`, {
188
+ status,
189
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "private, no-store", "x-robots-tag": "noindex" },
190
+ });
191
+ }
package/src/guard.ts CHANGED
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { defineMiddleware } from "astro:middleware";
12
12
 
13
+ import { signInUrl } from "./access.js";
14
+ import { keysFromPath, pathAccess, refusal } from "./files.js";
13
15
  import { siteConfig } from "./site-config.js";
14
16
 
15
17
  const SUBSCRIBER = 10;
@@ -28,11 +30,50 @@ export function memberMayUse(path: string, extra: string[] = []): boolean {
28
30
  return [...MEMBER_PATHS, ...extra].some((p) => path === p.replace(/\/$/, "") || path.startsWith(p));
29
31
  }
30
32
 
31
- export const onRequest = defineMiddleware(async ({ locals, url }, next) => {
33
+ /**
34
+ * EmDash skips the session on its public file route, so resolve the signed-in
35
+ * user here: the access check needs it.
36
+ */
37
+ async function withUser(context: { locals: unknown; session?: { get(k: string): Promise<unknown> } }) {
38
+ const locals = context.locals as { user?: { id: string; role?: number } | null; emdash?: { db?: unknown } | null };
39
+ if (locals.user || !locals.emdash?.db) return context;
40
+ const sessionUser = (await context.session?.get("user")) as { id?: string } | undefined;
41
+ if (!sessionUser?.id) return context;
42
+ const { createKyselyAdapter } = await import("@emdash-cms/auth/adapters/kysely");
43
+ const user = await createKyselyAdapter(locals.emdash.db as never).getUserById(sessionUser.id);
44
+ if (!user || user.disabled) return context;
45
+ return { ...context, locals: { ...locals, user: { id: user.id, role: user.role, disabled: user.disabled } } };
46
+ }
47
+
48
+ export const onRequest = defineMiddleware(async (context, next) => {
49
+ const { locals, url } = context;
32
50
  const user = (locals as { user?: { role?: number } | null }).user;
51
+ const config = siteConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
52
+
53
+ // Protected files: checked for everyone, on every download.
54
+ const keys = config?.files?.length ? keysFromPath(url.pathname) : null;
55
+ if (keys) {
56
+ let verdict;
57
+ try {
58
+ verdict = await pathAccess(await withUser(context), config, keys);
59
+ } catch (e) {
60
+ // Fail closed: a file we can't check isn't served.
61
+ console.error("[redeemid] file check failed:", (e as Error).message);
62
+ return refusal("denied", signInUrl(url.pathname), config);
63
+ }
64
+ if (verdict.protected) {
65
+ if (!verdict.allowed) return refusal(verdict.reason, signInUrl(url.pathname), config);
66
+ const res = await next();
67
+ const out = new Response(res.body, res);
68
+ out.headers.set("cache-control", "private, no-store");
69
+ out.headers.set("vary", "Cookie");
70
+ out.headers.set("x-robots-tag", "noindex");
71
+ return out;
72
+ }
73
+ }
74
+
33
75
  if (!user || user.role !== SUBSCRIBER) return next();
34
76
 
35
- const config = siteConfig((locals as { emdash?: { config?: unknown } }).emdash?.config);
36
77
  if (memberMayUse(url.pathname, config?.memberPaths)) return next();
37
78
 
38
79
  if (url.pathname.startsWith("/_emdash/admin")) {
package/src/index.ts CHANGED
@@ -58,6 +58,7 @@ export function redeemid(config: RedeemIDConfig): AuthProviderDescriptor {
58
58
  { pattern: `${BASE}/cart`, entrypoint: "@redeemid/emdash/routes/cart.ts" },
59
59
  { pattern: `${BASE}/cart-edit`, entrypoint: "@redeemid/emdash/routes/cart-edit.ts" },
60
60
  { pattern: `${BASE}/account`, entrypoint: "@redeemid/emdash/routes/account.ts" },
61
+ { pattern: `${BASE}/preferences`, entrypoint: "@redeemid/emdash/routes/preferences.ts" },
61
62
  ],
62
63
  publicRoutes: [`${BASE}/`],
63
64
  storage: STORAGE_CONFIG,
package/src/plugin.ts CHANGED
@@ -33,6 +33,9 @@ import { discover } from "./shopify.js";
33
33
  import { stores, type MemberRecord } from "./storage.js";
34
34
  import { PAGES, WIDGETS } from "./descriptor.js";
35
35
  import { VERSION } from "./version.js";
36
+ import { adminConfigured } from "./admin-api.js";
37
+ import { accessTest, bulkResync, BULK_BATCH, deliveriesSection, memberDetail, registerWebhooks, resyncOne, tagEdit, webhooksSection } from "./staff.js";
38
+ import { textProblems } from "./text.js";
36
39
 
37
40
  import {
38
41
  COLLECTION_BLOCK,
@@ -41,10 +44,11 @@ import {
41
44
  MEMBERS_END_BLOCK,
42
45
  PAYWALL_BLOCK,
43
46
  PLAN_BLOCK,
47
+ PREFERENCES_BLOCK,
44
48
  PRODUCT_BLOCK,
45
49
  } from "./blocks-names.js";
46
50
 
47
- export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PRODUCT_BLOCK };
51
+ export { COLLECTION_BLOCK, CONTRIBUTION_BLOCK, MEMBERS_BLOCK, MEMBERS_END_BLOCK, PAYWALL_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK, PRODUCT_BLOCK };
48
52
 
49
53
  function blocks(config: RedeemIDConfig) {
50
54
  const levelOptions = [
@@ -138,6 +142,17 @@ function blocks(config: RedeemIDConfig) {
138
142
  { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Give" },
139
143
  ],
140
144
  },
145
+ {
146
+ type: PREFERENCES_BLOCK,
147
+ label: "Preferences",
148
+ icon: "users",
149
+ description: "The signed-in member's preferences, saved on their Shopify customer",
150
+ placeholder: "Member preferences",
151
+ fields: [
152
+ { type: "text_input" as const, action_id: "title", label: "Title", placeholder: "Your preferences" },
153
+ { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Save preferences" },
154
+ ],
155
+ },
141
156
  ];
142
157
  }
143
158
 
@@ -154,11 +169,13 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
154
169
  routes: {
155
170
  admin: {
156
171
  handler: async (ctx: RouteContext) => {
157
- const i = (ctx.input ?? {}) as { type?: string; page?: string };
158
- if (i.type !== "page_load") return { blocks: [] };
172
+ const i = (ctx.input ?? {}) as { type?: string; page?: string; action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string };
159
173
  try {
174
+ if (i.type === "block_action" || i.type === "form_submit") return await action(config, ctx.request, i);
175
+ if (i.type !== "page_load") return { blocks: [] };
160
176
  if (i.page === "/") return await membersPage(config);
161
177
  if (i.page === "/status") return await statusPage(config, ctx.request);
178
+ if (i.page === "/test") return await accessTest(config);
162
179
  if (i.page === "widget:status") return await statusWidget(config);
163
180
  } catch (e) {
164
181
  return { blocks: [{ type: "banner", variant: "error", title: "RedeemID couldn't load this page", description: (e as Error).message }] };
@@ -172,6 +189,75 @@ export function createPlugin(config: RedeemIDConfig = {}): ResolvedPlugin {
172
189
 
173
190
  export default createPlugin;
174
191
 
192
+ // ---------------------------------------------------------------- actions
193
+
194
+ const webhookUri = (request: Request) => `${new URL(request.url).origin}/_emdash/api/auth/redeemid/webhook`;
195
+
196
+ async function action(
197
+ config: RedeemIDConfig,
198
+ request: Request,
199
+ i: { action_id?: string; value?: unknown; values?: Record<string, unknown>; block_id?: string },
200
+ ) {
201
+ const s = await memberStores();
202
+ const fail = (e: unknown) => ({ type: "error" as const, message: (e as Error).message });
203
+ switch (i.action_id) {
204
+ case "members_back":
205
+ return membersPage(config);
206
+ case "member_open": {
207
+ const m = await s.members.get(String(i.value ?? ""));
208
+ return m ? memberDetail(config, m) : { ...(await membersPage(config)), toast: { type: "error", message: "That member is gone." } };
209
+ }
210
+ case "member_resync": {
211
+ const id = String(i.value ?? "");
212
+ try {
213
+ const m = await resyncOne(config, s, id, "staff resync");
214
+ const now = m ?? (await s.members.get(id));
215
+ if (!now) return membersPage(config);
216
+ return memberDetail(config, now, m ? { type: "success", message: "Read again from Shopify." } : { type: "error", message: "Shopify no longer has this customer." });
217
+ } catch (e) {
218
+ const m = await s.members.get(id);
219
+ return m ? memberDetail(config, m, fail(e)) : membersPage(config);
220
+ }
221
+ }
222
+ case "member_tag_add":
223
+ case "member_tag_remove": {
224
+ const add = i.action_id === "member_tag_add";
225
+ const v = (i.value ?? {}) as { customerId?: string; tag?: string };
226
+ const id = add ? String(i.block_id ?? "").replace(/^tags:/, "") : String(v.customerId ?? "");
227
+ try {
228
+ const m = await tagEdit(config, s, id, add ? "add" : "remove", add ? i.values?.tags : [v.tag]);
229
+ const now = m ?? (await s.members.get(id));
230
+ return now ? memberDetail(config, now, { type: "success", message: add ? "Tags added in Shopify." : "Tag removed in Shopify." }) : membersPage(config);
231
+ } catch (e) {
232
+ const m = await s.members.get(id);
233
+ return m ? memberDetail(config, m, fail(e)) : membersPage(config);
234
+ }
235
+ }
236
+ case "members_resync": {
237
+ try {
238
+ const r = await bulkResync(config, s);
239
+ return {
240
+ ...(await membersPage(config)),
241
+ toast: { type: "success", message: `Read ${r.done} members again (${r.changed} changed${r.missing ? `, ${r.missing} no longer in Shopify` : ""}).` },
242
+ };
243
+ } catch (e) {
244
+ return { ...(await membersPage(config)), toast: fail(e) };
245
+ }
246
+ }
247
+ case "webhooks_register": {
248
+ try {
249
+ const added = await registerWebhooks(config, webhookUri(request));
250
+ return { ...(await statusPage(config, request)), toast: { type: "success", message: added.length ? `Registered ${added.length} webhooks.` : "Nothing was missing." } };
251
+ } catch (e) {
252
+ return { ...(await statusPage(config, request)), toast: fail(e) };
253
+ }
254
+ }
255
+ case "access_test":
256
+ return accessTest(config, i.values);
257
+ }
258
+ return { blocks: [] };
259
+ }
260
+
175
261
  // ---------------------------------------------------------------- pages
176
262
 
177
263
  async function memberStores() {
@@ -210,6 +296,25 @@ async function membersPage(config: RedeemIDConfig) {
210
296
  type: "context",
211
297
  text: "Shopify customers who have signed in or that Shopify has sent. Levels come only from their Shopify tags; change tags in Shopify (or with Flow) and access follows.",
212
298
  },
299
+ adminConfigured()
300
+ ? {
301
+ type: "actions",
302
+ elements: [
303
+ {
304
+ type: "button",
305
+ action_id: "members_resync",
306
+ label: `Resync ${BULK_BATCH} from Shopify`,
307
+ style: "secondary",
308
+ confirm: {
309
+ title: "Read members again from Shopify?",
310
+ text: `Reads the ${BULK_BATCH} members synced longest ago from Shopify and updates their tags. Click again for the next ${BULK_BATCH}.`,
311
+ confirm: "Resync",
312
+ deny: "Cancel",
313
+ },
314
+ },
315
+ ],
316
+ }
317
+ : { type: "context", text: "Set the Admin API app's client ID (REDEEMID_SHOPIFY_ADMIN_APP_CLIENT_ID) to resync members and edit their tags from here." },
213
318
  {
214
319
  type: "stats",
215
320
  items: [
@@ -230,6 +335,7 @@ async function membersPage(config: RedeemIDConfig) {
230
335
  { key: "tags", label: "Shopify tags" },
231
336
  { key: "status", label: "Account" },
232
337
  { key: "synced", label: "Last synced", format: "relative_time" },
338
+ { key: "open", label: "", format: "element" },
233
339
  ],
234
340
  rows: members
235
341
  .sort((a, b) => b.syncedAt.localeCompare(a.syncedAt))
@@ -240,6 +346,7 @@ async function membersPage(config: RedeemIDConfig) {
240
346
  tags: m.tags.join(", ") || "—",
241
347
  status: m.status === "enabled" ? "On" : "Off",
242
348
  synced: m.syncedAt,
349
+ open: { type: "button", action_id: "member_open", label: "Open", value: m.customerId },
243
350
  })),
244
351
  },
245
352
  ],
@@ -270,6 +377,13 @@ async function lastDelivery(): Promise<string | null> {
270
377
  return d ? `${d.topic} at ${d.receivedAt}` : null;
271
378
  }
272
379
 
380
+ function textSummary(config: RedeemIDConfig): string {
381
+ const all = Object.keys(config.text ?? {}).length;
382
+ const ignored = textProblems(config).length;
383
+ if (!all) return "None";
384
+ return ignored ? `${all - ignored} in use, ${ignored} ignored` : `${all} in use`;
385
+ }
386
+
273
387
  async function statusPage(config: RedeemIDConfig, request: Request) {
274
388
  const c = await checks(config);
275
389
  const origin = new URL(request.url).origin;
@@ -277,6 +391,10 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
277
391
  const levels = config.levels ?? [];
278
392
  const rules = config.rules ?? [];
279
393
  const delivery = await lastDelivery().catch(() => null);
394
+ const webhooks = await webhooksSection(config, webhookUri(request));
395
+ const deliveries = await memberStores()
396
+ .then((s) => deliveriesSection(s))
397
+ .catch(() => [{ type: "context", text: "Couldn't read deliveries." }]);
280
398
  return {
281
399
  blocks: [
282
400
  { type: "header", text: "Status" },
@@ -290,10 +408,31 @@ async function statusPage(config: RedeemIDConfig, request: Request) {
290
408
  { label: "Store discovery", value: c.discovery },
291
409
  { label: "Customer Account API client ID", value: yes(c.clientId) },
292
410
  { label: "Webhook signing secret", value: yes(c.webhookSecret) },
411
+ { label: "Admin API app client ID (staff tools)", value: yes(Boolean(env(ENV.adminClientId))) },
412
+ { label: "Events", value: config.events?.url ? `${config.events.url}${env(ENV.eventsSecret) ? "" : " (REDEEMID_EVENTS_SECRET missing)"}` : "Off" },
293
413
  { label: "Storefront API access", value: c.storefront },
294
414
  { label: "Last webhook", value: delivery ?? "None received yet" },
415
+ { label: "Replacement text", value: textSummary(config) },
295
416
  ],
296
417
  },
418
+ ...(textProblems(config).length
419
+ ? [
420
+ {
421
+ type: "table",
422
+ page_action_id: "text_problems",
423
+ columns: [
424
+ { key: "original", label: "Ignored replacement for" },
425
+ { key: "problem", label: "Why" },
426
+ ],
427
+ rows: textProblems(config),
428
+ },
429
+ ]
430
+ : []),
431
+ { type: "divider" },
432
+ { type: "header", text: "Webhooks" },
433
+ ...webhooks,
434
+ { type: "header", text: "Recent deliveries" },
435
+ ...deliveries,
297
436
  { type: "divider" },
298
437
  { type: "header", text: "Shopify settings" },
299
438
  {