@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.
- package/README.md +127 -2
- package/docs/shopify-setup.md +5 -1
- package/package.json +5 -1
- package/src/access.ts +9 -3
- package/src/admin-api.ts +191 -0
- package/src/blocks/ContributionBlock.astro +1 -1
- package/src/blocks/PlanBlock.astro +1 -1
- package/src/blocks/PreferencesBlock.astro +12 -0
- package/src/blocks/index.ts +2 -0
- package/src/blocks-names.ts +1 -0
- package/src/components/AccessBadge.astro +4 -3
- package/src/components/Account.astro +5 -4
- package/src/components/Cart.astro +4 -1
- package/src/components/CartView.astro +14 -13
- package/src/components/Collection.astro +2 -1
- package/src/components/Content.astro +10 -4
- package/src/components/Contribution.astro +4 -3
- package/src/components/Feature.astro +20 -0
- package/src/components/LockedPanel.astro +10 -8
- package/src/components/MemberCard.astro +22 -13
- package/src/components/Notices.astro +10 -2
- package/src/components/Plan.astro +3 -2
- package/src/components/Preferences.astro +102 -0
- package/src/components/Product.astro +6 -5
- package/src/config.ts +53 -0
- package/src/descriptor.ts +1 -0
- package/src/events.ts +75 -0
- package/src/features.ts +39 -0
- package/src/files.ts +191 -0
- package/src/guard.ts +43 -2
- package/src/index.ts +1 -0
- package/src/plugin.ts +142 -3
- package/src/preferences.ts +298 -0
- package/src/routes/callback.ts +8 -1
- package/src/routes/preferences.ts +82 -0
- package/src/routes/webhook.ts +5 -3
- package/src/staff.ts +220 -0
- package/src/storage.ts +3 -1
- package/src/text.ts +174 -0
- package/src/version.ts +1 -1
- package/src/webhooks.ts +11 -2
package/src/features.ts
ADDED
|
@@ -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
|
-
|
|
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
|
{
|