@redeemid/emdash 0.0.29 → 0.0.31
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 +2 -2
- package/docs/access.md +7 -5
- package/docs/content.md +1 -1
- package/docs/developer-api.md +1 -1
- package/docs/getting-started.md +4 -4
- package/docs/production.md +3 -3
- package/docs/reference.md +3 -3
- package/docs/shopify-flow.md +2 -2
- package/docs/shopify-setup.md +2 -2
- package/docs/staff.md +18 -8
- package/package.json +11 -2
- package/src/access-admin.ts +211 -76
- package/src/access-map.ts +143 -0
- package/src/admin.ts +505 -0
- package/src/descriptor.ts +19 -9
- package/src/monitor.ts +2 -2
- package/src/plugin.ts +302 -76
- package/src/staff.ts +148 -66
- package/src/version.ts +1 -1
package/src/access-admin.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
import type { StorageCollection } from "emdash";
|
|
17
17
|
|
|
18
18
|
import { applyEntryRule, badgeColor, describe, requirement, ruleSource, SIGNED_IN, termTree, type Requirement, type Terms } from "./access.js";
|
|
19
|
+
import { accessMap, type MapGo } from "./access-map.js";
|
|
20
|
+
import { accessTest } from "./staff.js";
|
|
19
21
|
import { groupsOf, type FeatureConfig, type FileRule, type LevelConfig, type MeterConfig, type RedeemIDConfig, type RuleConfig, type TagGroup } from "./config.js";
|
|
20
22
|
import {
|
|
21
23
|
days,
|
|
@@ -62,12 +64,30 @@ export interface AccessEnv {
|
|
|
62
64
|
taxonomies: TaxonomyInfo[];
|
|
63
65
|
/** Terms of a taxonomy, parents before children, labels showing the path. */
|
|
64
66
|
terms(taxonomy: string): Promise<TermInfo[]>;
|
|
67
|
+
/** Members holding each level, for the map (left out in tests). */
|
|
68
|
+
holders?(): Promise<Record<string, number>>;
|
|
65
69
|
}
|
|
66
70
|
|
|
67
|
-
export const TABS = ["levels", "site", "types", "taxonomies", "features", "files"] as const;
|
|
71
|
+
export const TABS = ["map", "levels", "site", "types", "taxonomies", "features", "files", "test"] as const;
|
|
68
72
|
export type Tab = (typeof TABS)[number];
|
|
69
|
-
/** Which
|
|
70
|
-
const PANEL: Record<Tab, number> = {
|
|
73
|
+
/** Which panel holds each part: Map, Levels, Features, Rules, Terms, Files, Test. */
|
|
74
|
+
const PANEL: Record<Tab, number> = { map: 0, levels: 1, features: 2, site: 3, types: 3, taxonomies: 4, files: 5, test: 6 };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A button that opens a dialog around `blocks` (rendered by admin.ts). With
|
|
78
|
+
* `open` and no `label` it opens straight away, for Edit picked in a table.
|
|
79
|
+
*/
|
|
80
|
+
function modal(id: string, title: string, blocks: unknown[], opts: { label?: string; style?: "primary" | "secondary"; description?: string; open?: boolean } = {}) {
|
|
81
|
+
return {
|
|
82
|
+
type: "redeemid_modal",
|
|
83
|
+
block_id: `modal:${id}`,
|
|
84
|
+
title,
|
|
85
|
+
...(opts.description ? { description: opts.description } : {}),
|
|
86
|
+
...(opts.label ? { trigger: { label: opts.label, style: opts.style ?? "primary" } } : {}),
|
|
87
|
+
...(opts.open ? { open: true } : {}),
|
|
88
|
+
blocks,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
71
91
|
|
|
72
92
|
const effective = (env: AccessEnv) => mergeAccess(env.code, env.saved) ?? env.code;
|
|
73
93
|
|
|
@@ -189,16 +209,78 @@ export function parseGroups(raw: unknown, firstMatch?: unknown): TagGroup[] {
|
|
|
189
209
|
|
|
190
210
|
// ---------------------------------------------------------------- tabs
|
|
191
211
|
|
|
212
|
+
const API_DOCS = "https://github.com/webmultipliers/redeemid-emdash/blob/main/docs/developer-api.md";
|
|
213
|
+
|
|
214
|
+
/** Astro code that checks a level, with its slug filled in. */
|
|
215
|
+
function levelCode(slug: string): string {
|
|
216
|
+
return `---
|
|
217
|
+
import Gate from "@redeemid/emdash/components/Gate.astro";
|
|
218
|
+
import { viewer } from "@redeemid/emdash/access";
|
|
219
|
+
const reader = await viewer(Astro);
|
|
220
|
+
---
|
|
221
|
+
<Gate levels={["${slug}"]}>
|
|
222
|
+
<p>Only this level sees this part.</p>
|
|
223
|
+
<p slot="locked">Join to read it.</p>
|
|
224
|
+
</Gate>
|
|
225
|
+
{reader.levels.includes("${slug}") && <p>Thanks for being a member.</p>}`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Astro code that checks a feature, with its slug filled in. */
|
|
229
|
+
function featureCode(slug: string): string {
|
|
230
|
+
return `---
|
|
231
|
+
import Feature from "@redeemid/emdash/components/Feature.astro";
|
|
232
|
+
import { hasFeature } from "@redeemid/emdash/features";
|
|
233
|
+
const on = await hasFeature(Astro, "${slug}");
|
|
234
|
+
---
|
|
235
|
+
<Feature slug="${slug}">
|
|
236
|
+
<p>Shown to members who hold it.</p>
|
|
237
|
+
<p slot="else">Shown to everyone else.</p>
|
|
238
|
+
</Feature>
|
|
239
|
+
{!on && <aside class="ad">…</aside>}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A closed "Use it in your site" section: who grants it (Shopify), the code
|
|
244
|
+
* that checks it, and where the full API is.
|
|
245
|
+
*/
|
|
246
|
+
function inSite(kind: "level" | "feature", slug: string, named?: string): unknown {
|
|
247
|
+
const grant = "Who holds it is decided in Shopify: Flow, a subscription app, or staff on a member's Tags tab. Nothing on this site grants it.";
|
|
248
|
+
const what =
|
|
249
|
+
kind === "level"
|
|
250
|
+
? "Content rules here lock posts to a level without code. In templates, <Gate levels> locks part of a page, and viewer(Astro).levels or membership() says what the reader holds. In the browser, window.redeemid's state event lists the reader's levels."
|
|
251
|
+
: "A feature does nothing until the site's code checks it. Use <Feature slug> to show or hide part of a page, or hasFeature(Astro, slug) in code. Protected folders on the Files tab can also open to a feature. In the browser, window.redeemid's state event lists the reader's features.";
|
|
252
|
+
return {
|
|
253
|
+
type: "accordion",
|
|
254
|
+
label: named ? `Use ${named} in your site` : "Use it in your site",
|
|
255
|
+
blocks: [
|
|
256
|
+
{ type: "context", text: grant },
|
|
257
|
+
{ type: "context", text: what },
|
|
258
|
+
{ type: "code", language: "astro", code: kind === "level" ? levelCode(slug) : featureCode(slug) },
|
|
259
|
+
{ type: "actions", elements: [{ type: "link", label: "Developer API", target: { kind: "external", url: API_DOCS }, appearance: "secondary" }] },
|
|
260
|
+
],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The whole picture: who gets what, and through which level. */
|
|
265
|
+
async function mapTab(env: AccessEnv): Promise<unknown[]> {
|
|
266
|
+
const config = effective(env);
|
|
267
|
+
const holders = env.holders ? await env.holders().catch(() => undefined) : undefined;
|
|
268
|
+
const rows = accessMap({ config, entries: env.saved.entries, collections: env.collections, taxonomies: env.taxonomies, tagWords: groupsWords, holders });
|
|
269
|
+
return [
|
|
270
|
+
{ type: "context", text: "Who gets what. Pick a row to see everything it opens and where to change it. A level also gets what its included levels, signed-in members and everyone get, counted as \"via\". This shows which settings name a reader; to see which rule wins on one post, use the Test tab." },
|
|
271
|
+
{ type: "redeemid_map", action_id: "map_go", rows },
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
|
|
192
275
|
function levelsTab(env: AccessEnv, editing?: string): unknown[] {
|
|
193
276
|
const config = effective(env);
|
|
194
277
|
const levels = config.levels ?? [];
|
|
195
278
|
const rules = config.rules ?? [];
|
|
196
279
|
const used = (slug: string) => rules.filter((r) => r.levels.includes(slug)).length;
|
|
197
280
|
const current = editing ? levels.find((l) => l.slug === editing) : undefined;
|
|
198
|
-
const others = levels.filter((l) => l.slug !== current?.slug);
|
|
199
281
|
return [
|
|
200
|
-
{ type: "context", text: "A level
|
|
201
|
-
|
|
282
|
+
{ type: "context", text: "A level names the readers who hold some Shopify customer tags, and rules lock content to it. Tags come from Shopify (Flow, a subscription app or staff), never from this site. A level that includes another also reads everything that one reads. Features (next tab) add perks on top." },
|
|
283
|
+
modal("level:new", "Add a level", levelForm(levels), { label: "Add a level", description: TAG_HELP }),
|
|
202
284
|
levels.length
|
|
203
285
|
? {
|
|
204
286
|
type: "table",
|
|
@@ -221,35 +303,40 @@ function levelsTab(env: AccessEnv, editing?: string): unknown[] {
|
|
|
221
303
|
act: { type: "menu", action_id: "level_menu", label: "Edit", items: [{ label: "Edit", value: `edit:${l.slug}` }, { label: "Remove", value: `remove:${l.slug}` }] },
|
|
222
304
|
})),
|
|
223
305
|
}
|
|
224
|
-
: { type: "empty", title: "No levels yet", description: "Add one
|
|
225
|
-
|
|
226
|
-
|
|
306
|
+
: { type: "empty", title: "No levels yet", description: "Add one, such as Members for the Shopify tag member.", size: "sm" },
|
|
307
|
+
inSite("level", levels[0]?.slug ?? "members"),
|
|
308
|
+
...(current ? [modal(`level:${current.slug}`, `Edit ${current.name}`, [...levelForm(levels, current), inSite("level", current.slug, current.name)], { open: true, description: TAG_HELP })] : []),
|
|
309
|
+
...sourceLine(env, "levels", "Levels"),
|
|
310
|
+
];
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const TAG_HELP = "Shopify tags: one group per line, commas between tags. A line passes when the customer has any one of its tags; start it with all: to need every tag. Any line grants the level.";
|
|
314
|
+
|
|
315
|
+
/** The level form: what a level needs first, the optional extras behind a switch. */
|
|
316
|
+
function levelForm(levels: LevelConfig[], current?: LevelConfig): unknown[] {
|
|
317
|
+
const others = levels.filter((l) => l.slug !== current?.slug);
|
|
318
|
+
const extras = Boolean(current && (current.wall || current.joinUrl || current.products?.length || current.home || current.badge));
|
|
319
|
+
const more = { field: "more", eq: true };
|
|
320
|
+
return [
|
|
227
321
|
{
|
|
228
322
|
type: "form",
|
|
229
323
|
block_id: current ? `level:${current.slug}` : "level:new",
|
|
230
324
|
fields: [
|
|
231
325
|
{ type: "text_input", action_id: "name", label: "Name", placeholder: "Members", initial_value: current?.name ?? "" },
|
|
232
326
|
...(current ? [] : [{ type: "text_input", action_id: "slug", label: "Slug (optional, fixed once saved)", placeholder: "members" }]),
|
|
233
|
-
{
|
|
234
|
-
type: "text_input",
|
|
235
|
-
action_id: "tags",
|
|
236
|
-
label: "Shopify tags: commas between tags, one group per line",
|
|
237
|
-
placeholder: "gold\nall: silver, annual",
|
|
238
|
-
initial_value: groupsText(current),
|
|
239
|
-
multiline: true,
|
|
240
|
-
},
|
|
327
|
+
{ type: "text_input", action_id: "tags", label: "Shopify tags", placeholder: "gold\nall: silver, annual", initial_value: groupsText(current), multiline: true },
|
|
241
328
|
...(others.length
|
|
242
|
-
? [{ type: "checkbox", action_id: "includes", label: "Also
|
|
329
|
+
? [{ type: "checkbox", action_id: "includes", label: "Also reads what these levels read", options: others.map((l) => ({ label: l.name, value: l.slug })), initial_value: current?.includes ?? [] }]
|
|
243
330
|
: []),
|
|
244
|
-
{ type: "
|
|
245
|
-
{ type: "text_input", action_id: "
|
|
246
|
-
{ type: "text_input", action_id: "
|
|
247
|
-
{ type: "text_input", action_id: "
|
|
248
|
-
{ type: "text_input", action_id: "
|
|
331
|
+
{ type: "toggle", action_id: "more", label: "More options", description: "Wall text, join link, plan products, members' home and badge colour", initial_value: extras },
|
|
332
|
+
{ type: "text_input", action_id: "wall", label: "Wall text", initial_value: current?.wall ?? "", multiline: true, condition: more },
|
|
333
|
+
{ type: "text_input", action_id: "join_url", label: "Join link", placeholder: "/join/", initial_value: current?.joinUrl ?? "", condition: more },
|
|
334
|
+
{ type: "text_input", action_id: "products", label: "Shopify product handles that lead to it (for plan cards)", placeholder: "gold-membership", initial_value: current?.products?.join(", ") ?? "", condition: more },
|
|
335
|
+
{ type: "text_input", action_id: "home", label: "Members' home after sign-in", placeholder: "/members/gold/", initial_value: current?.home ?? "", condition: more },
|
|
336
|
+
{ type: "text_input", action_id: "badge", label: "Badge colour", placeholder: "#c9a227", initial_value: current?.badge ?? "", condition: more },
|
|
249
337
|
],
|
|
250
338
|
submit: { label: current ? "Save level" : "Add level", action_id: "level_save" },
|
|
251
339
|
},
|
|
252
|
-
...(current ? [{ type: "actions", elements: [{ type: "button", action_id: "level_cancel", label: "Cancel", style: "secondary" }] }] : []),
|
|
253
340
|
];
|
|
254
341
|
}
|
|
255
342
|
|
|
@@ -259,23 +346,34 @@ function siteTab(env: AccessEnv): unknown[] {
|
|
|
259
346
|
const site = (config.rules ?? []).find((r) => r.collection === "*" && !r.taxonomy && !r.term);
|
|
260
347
|
const meter = config.meter;
|
|
261
348
|
return [
|
|
262
|
-
{ type: "
|
|
263
|
-
|
|
349
|
+
{ type: "header", text: "Site default" },
|
|
350
|
+
{ type: "context", text: "Who can read when no content type, term or entry rule says otherwise." },
|
|
264
351
|
{
|
|
265
352
|
type: "form",
|
|
266
353
|
block_id: "site",
|
|
267
354
|
fields: [
|
|
268
355
|
...ruleFields(levels, site, "Free to everyone", false),
|
|
269
|
-
{ type: "select", action_id: "combine", label: "
|
|
270
|
-
|
|
271
|
-
|
|
356
|
+
{ type: "select", action_id: "combine", label: "When an entry has several term rules", options: [{ label: "Readers pass every one", value: "all" }, { label: "Readers pass any one", value: "any" }], initial_value: config.combine ?? "all" },
|
|
357
|
+
],
|
|
358
|
+
submit: { label: "Save site default", action_id: "site_save" },
|
|
359
|
+
},
|
|
360
|
+
...sourceLine(env, "rules", "Rules"),
|
|
361
|
+
{ type: "divider" },
|
|
362
|
+
{ type: "header", text: "Meter and join link" },
|
|
363
|
+
{ type: "context", text: "The meter lets signed-in readers open a few locked entries each period. The join link is where walls send readers without a level." },
|
|
364
|
+
{
|
|
365
|
+
type: "form",
|
|
366
|
+
block_id: "site:meter",
|
|
367
|
+
fields: [
|
|
368
|
+
{ type: "number_input", action_id: "meter_limit", label: "Free locked reads per period (0 = off)", min: 0, max: 1000, initial_value: meter?.limit ?? 0 },
|
|
369
|
+
{ type: "select", action_id: "meter_period", label: "Period", options: [{ label: "Month", value: "month" }, { label: "Week", value: "week" }], initial_value: meter?.period ?? "month" },
|
|
272
370
|
...(env.collections.length
|
|
273
371
|
? [{ type: "checkbox", action_id: "meter_collections", label: "Meter covers (none ticked = all)", options: env.collections.map((c) => ({ label: c.label, value: c.slug })), initial_value: meter?.collections ?? [] }]
|
|
274
372
|
: []),
|
|
275
373
|
{ type: "text_input", action_id: "join_href", label: "Join link", placeholder: "/join/", initial_value: config.join?.href ?? "" },
|
|
276
374
|
{ type: "text_input", action_id: "join_label", label: "Join link text", placeholder: "Become a member", initial_value: config.join?.label ?? "" },
|
|
277
375
|
],
|
|
278
|
-
submit: { label: "Save
|
|
376
|
+
submit: { label: "Save meter and join link", action_id: "site_save" },
|
|
279
377
|
},
|
|
280
378
|
...sourceLine(env, "meter", "Meter"),
|
|
281
379
|
...sourceLine(env, "join", "Join link"),
|
|
@@ -288,8 +386,8 @@ function typesTab(env: AccessEnv): unknown[] {
|
|
|
288
386
|
const rules = config.rules ?? [];
|
|
289
387
|
if (env.collections.length === 0) return [{ type: "empty", title: "No content types yet", size: "sm" }];
|
|
290
388
|
return [
|
|
389
|
+
{ type: "header", text: "Content types" },
|
|
291
390
|
{ type: "context", text: "Who can read each content type. New content types appear here as soon as they're made. Term rules (Terms tab) and an entry's own rule (its Access panel) beat these." },
|
|
292
|
-
...sourceLine(env, "rules", "Rules"),
|
|
293
391
|
...env.collections.map((c) => {
|
|
294
392
|
const rule = rules.find((r) => r.collection === c.slug && !r.taxonomy && !r.term);
|
|
295
393
|
const teaser = config.teaser?.[c.slug] ?? 0;
|
|
@@ -354,16 +452,23 @@ async function taxonomiesTab(env: AccessEnv): Promise<unknown[]> {
|
|
|
354
452
|
}
|
|
355
453
|
: { type: "context", text: "No rules: its terms don't change who can read." },
|
|
356
454
|
terms.length
|
|
357
|
-
?
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
{
|
|
362
|
-
|
|
363
|
-
|
|
455
|
+
? modal(
|
|
456
|
+
`term:${t.name}`,
|
|
457
|
+
`Add a ${t.label} rule`,
|
|
458
|
+
[
|
|
459
|
+
{
|
|
460
|
+
type: "form",
|
|
461
|
+
block_id: `term:${t.name}`,
|
|
462
|
+
fields: [
|
|
463
|
+
{ type: "combobox", action_id: "term", label: "Term", options: terms.map((x) => ({ label: x.label, value: x.slug })) },
|
|
464
|
+
{ type: "select", action_id: "scope", label: "In", options: [{ label: "Every content type", value: "*" }, ...t.collections.map((c) => ({ label: label(c), value: c }))], initial_value: "*" },
|
|
465
|
+
...ruleFields(levels, undefined, null),
|
|
466
|
+
],
|
|
467
|
+
submit: { label: "Save term rule", action_id: "term_save" },
|
|
468
|
+
},
|
|
364
469
|
],
|
|
365
|
-
|
|
366
|
-
|
|
470
|
+
{ label: "Add a term rule", style: "secondary", description: "Saving a rule for a term that has one replaces it." },
|
|
471
|
+
)
|
|
367
472
|
: { type: "context", text: "Add terms to this taxonomy to give them rules." },
|
|
368
473
|
],
|
|
369
474
|
});
|
|
@@ -377,8 +482,8 @@ function featuresTab(env: AccessEnv, editing?: string): unknown[] {
|
|
|
377
482
|
const features = config.features ?? [];
|
|
378
483
|
const current = editing ? features.find((f) => f.slug === editing) : undefined;
|
|
379
484
|
return [
|
|
380
|
-
{ type: "context", text: "
|
|
381
|
-
|
|
485
|
+
{ type: "context", text: "A level says what a member is and decides what they can read. A feature says what they get, such as ad-free reading: a switch the site's code checks. Give a feature to levels (the usual way) or to a Shopify tag for a one-off perk." },
|
|
486
|
+
modal("feature:new", "Add a feature", featureForm(levels), { label: "Add a feature" }),
|
|
382
487
|
features.length
|
|
383
488
|
? {
|
|
384
489
|
type: "table",
|
|
@@ -398,8 +503,15 @@ function featuresTab(env: AccessEnv, editing?: string): unknown[] {
|
|
|
398
503
|
act: { type: "menu", action_id: "feature_menu", label: "Edit", items: [{ label: "Edit", value: `edit:${f.slug}` }, { label: "Remove", value: `remove:${f.slug}` }] },
|
|
399
504
|
})),
|
|
400
505
|
}
|
|
401
|
-
: { type: "
|
|
402
|
-
|
|
506
|
+
: { type: "empty", title: "No features yet", description: "Add one, such as Ad-free for the Members level.", size: "sm" },
|
|
507
|
+
inSite("feature", features[0]?.slug ?? "ad-free"),
|
|
508
|
+
...(current ? [modal(`feature:${current.slug}`, `Edit ${current.name}`, [...featureForm(levels, current), inSite("feature", current.slug, current.name)], { open: true })] : []),
|
|
509
|
+
...sourceLine(env, "features", "Features"),
|
|
510
|
+
];
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function featureForm(levels: LevelConfig[], current?: FeatureConfig): unknown[] {
|
|
514
|
+
return [
|
|
403
515
|
{
|
|
404
516
|
type: "form",
|
|
405
517
|
block_id: current ? `feature:${current.slug}` : "feature:new",
|
|
@@ -407,13 +519,26 @@ function featuresTab(env: AccessEnv, editing?: string): unknown[] {
|
|
|
407
519
|
{ type: "text_input", action_id: "name", label: "Name", placeholder: "Ad-free", initial_value: current?.name ?? "" },
|
|
408
520
|
...(current ? [] : [{ type: "text_input", action_id: "slug", label: "Slug (optional, fixed once saved)", placeholder: "ad-free" }]),
|
|
409
521
|
...(levels.length ? [{ type: "checkbox", action_id: "levels", label: "Levels that hold it", options: levelOptions(levels), initial_value: current?.levels ?? [] }] : []),
|
|
410
|
-
{ type: "text_input", action_id: "tags", label: "Shopify tags
|
|
522
|
+
{ type: "text_input", action_id: "tags", label: "Shopify tags that hold it (comma separated, optional)", initial_value: current?.tags?.join(", ") ?? "" },
|
|
411
523
|
],
|
|
412
524
|
submit: { label: current ? "Save feature" : "Add feature", action_id: "feature_save" },
|
|
413
525
|
},
|
|
414
526
|
];
|
|
415
527
|
}
|
|
416
528
|
|
|
529
|
+
const FILE_FORM = (levels: LevelConfig[], features: FeatureConfig[]) => ({
|
|
530
|
+
type: "form",
|
|
531
|
+
block_id: "file",
|
|
532
|
+
fields: [
|
|
533
|
+
{ type: "text_input", action_id: "folder", label: "Media folder name", placeholder: "Member downloads" },
|
|
534
|
+
{ type: "toggle", action_id: "members", label: "Signed-in members", initial_value: true },
|
|
535
|
+
...(levels.length ? [{ type: "checkbox", action_id: "levels", label: "Only these levels (none = any member)", options: levelOptions(levels), condition: { field: "members", eq: true } }] : []),
|
|
536
|
+
...(features.length ? [{ type: "checkbox", action_id: "features", label: "Members with these features", options: features.map((f) => ({ label: f.name, value: f.slug })) }] : []),
|
|
537
|
+
{ type: "toggle", action_id: "entries", label: "Readers of an entry that uses the file", initial_value: false },
|
|
538
|
+
],
|
|
539
|
+
submit: { label: "Save folder", action_id: "file_save" },
|
|
540
|
+
});
|
|
541
|
+
|
|
417
542
|
function filesTab(env: AccessEnv): unknown[] {
|
|
418
543
|
const config = effective(env);
|
|
419
544
|
const levels = config.levels ?? [];
|
|
@@ -421,7 +546,7 @@ function filesTab(env: AccessEnv): unknown[] {
|
|
|
421
546
|
const files = config.files ?? [];
|
|
422
547
|
return [
|
|
423
548
|
{ type: "context", text: "Media folders whose files only some readers may download. Any of the ways in is enough." },
|
|
424
|
-
|
|
549
|
+
modal("file", "Protect a folder", [FILE_FORM(levels, features)], { label: "Protect a folder" }),
|
|
425
550
|
files.length
|
|
426
551
|
? {
|
|
427
552
|
type: "table",
|
|
@@ -442,30 +567,21 @@ function filesTab(env: AccessEnv): unknown[] {
|
|
|
442
567
|
})),
|
|
443
568
|
}
|
|
444
569
|
: { type: "context", text: "No protected folders: every file is public." },
|
|
445
|
-
|
|
446
|
-
{
|
|
447
|
-
type: "form",
|
|
448
|
-
block_id: "file",
|
|
449
|
-
fields: [
|
|
450
|
-
{ type: "text_input", action_id: "folder", label: "Media folder name", placeholder: "Member downloads" },
|
|
451
|
-
{ type: "toggle", action_id: "members", label: "Signed-in members", initial_value: true },
|
|
452
|
-
...(levels.length ? [{ type: "checkbox", action_id: "levels", label: "Only these levels (none = any member)", options: levelOptions(levels), condition: { field: "members", eq: true } }] : []),
|
|
453
|
-
...(features.length ? [{ type: "checkbox", action_id: "features", label: "Features", options: features.map((f) => ({ label: f.name, value: f.slug })) }] : []),
|
|
454
|
-
{ type: "toggle", action_id: "entries", label: "Readers of an entry that uses it", initial_value: false },
|
|
455
|
-
],
|
|
456
|
-
submit: { label: "Save folder", action_id: "file_save" },
|
|
457
|
-
},
|
|
570
|
+
...sourceLine(env, "files", "Protected files"),
|
|
458
571
|
];
|
|
459
572
|
}
|
|
460
573
|
|
|
461
|
-
/** The Access page, opened on `tab`. */
|
|
462
|
-
export async function accessPage(env: AccessEnv, tab: Tab = "
|
|
574
|
+
/** The Access page, opened on `tab`. `test` holds the Test tab's last answer. */
|
|
575
|
+
export async function accessPage(env: AccessEnv, tab: Tab = "map", toast?: Toast, edit?: { level?: string; feature?: string }, test?: Record<string, unknown>): Promise<Blocks> {
|
|
576
|
+
const tested = (await accessTest(effective(env), test)).blocks.filter((b) => (b as { type: string }).type !== "header");
|
|
463
577
|
const panels = [
|
|
464
|
-
|
|
465
|
-
{ label: "Levels", blocks:
|
|
466
|
-
{ label: "
|
|
578
|
+
{ label: "Map", blocks: await mapTab(env) },
|
|
579
|
+
{ label: "Levels", blocks: levelsTab(env, edit?.level) },
|
|
580
|
+
{ label: "Features", blocks: featuresTab(env, edit?.feature) },
|
|
581
|
+
{ label: "Rules", blocks: [...siteTab(env), { type: "divider" }, ...typesTab(env)] },
|
|
467
582
|
{ label: "Terms", blocks: await taxonomiesTab(env) },
|
|
468
583
|
{ label: "Files", blocks: filesTab(env) },
|
|
584
|
+
{ label: "Test", blocks: tested },
|
|
469
585
|
];
|
|
470
586
|
return {
|
|
471
587
|
toast,
|
|
@@ -511,7 +627,7 @@ export interface Interaction {
|
|
|
511
627
|
values?: Record<string, unknown>;
|
|
512
628
|
}
|
|
513
629
|
|
|
514
|
-
const ACCESS_ACTIONS = new Set(["reset", "level_menu", "level_cancel", "level_save", "site_save", "type_save", "term_save", "term_remove", "feature_menu", "feature_save", "file_save", "file_remove"]);
|
|
630
|
+
const ACCESS_ACTIONS = new Set(["map_go", "access_test", "reset", "level_menu", "level_cancel", "level_save", "site_save", "type_save", "term_save", "term_remove", "feature_menu", "feature_save", "file_save", "file_remove"]);
|
|
515
631
|
|
|
516
632
|
export function isAccessAction(id: string | undefined): boolean {
|
|
517
633
|
return Boolean(id && ACCESS_ACTIONS.has(id));
|
|
@@ -525,6 +641,18 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
|
|
|
525
641
|
const target = String(i.block_id ?? "").split(":").slice(1).join(":");
|
|
526
642
|
|
|
527
643
|
switch (i.action_id) {
|
|
644
|
+
case "map_go": {
|
|
645
|
+
// From the map to where that part is changed.
|
|
646
|
+
const go = JSON.parse(String(i.value ?? "{}")) as MapGo;
|
|
647
|
+
if ("level" in go) return accessPage(env, "levels", undefined, { level: go.level });
|
|
648
|
+
if ("feature" in go) return accessPage(env, "features", undefined, { feature: go.feature });
|
|
649
|
+
if ("tab" in go && (TABS as readonly string[]).includes(go.tab)) return accessPage(env, go.tab);
|
|
650
|
+
return accessPage(env);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
case "access_test":
|
|
654
|
+
return accessPage(env, "test", undefined, undefined, values);
|
|
655
|
+
|
|
528
656
|
case "reset": {
|
|
529
657
|
const key = String(i.value ?? "") as ManagedKey;
|
|
530
658
|
if (!MANAGED.includes(key)) return accessPage(env);
|
|
@@ -600,8 +728,12 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
|
|
|
600
728
|
}
|
|
601
729
|
|
|
602
730
|
case "site_save": {
|
|
731
|
+
// Two forms save here: the site default ("who") and the meter with
|
|
732
|
+
// the join link ("meter_limit"). Each changes only its own settings.
|
|
603
733
|
const now = effective(env);
|
|
604
|
-
const
|
|
734
|
+
const ruleForm = "who" in values;
|
|
735
|
+
const meterForm = "meter_limit" in values;
|
|
736
|
+
const rule = ruleForm ? ruleFrom(values, { collection: "*" }, now.levels ?? []) : null;
|
|
605
737
|
if (rule && "error" in rule) return bad("site", rule.error);
|
|
606
738
|
const limit = Math.max(0, Math.floor(Number(values.meter_limit) || 0));
|
|
607
739
|
const known = new Set(env.collections.map((c) => c.slug));
|
|
@@ -610,17 +742,20 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
|
|
|
610
742
|
const href = String(values.join_href ?? "").trim();
|
|
611
743
|
const label = String(values.join_label ?? "").trim();
|
|
612
744
|
await saveSite(env, (site, n) => {
|
|
613
|
-
const
|
|
614
|
-
if (
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
rules
|
|
618
|
-
combine
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
745
|
+
const out: SiteAccess = { ...site };
|
|
746
|
+
if (ruleForm) {
|
|
747
|
+
const rules = start(site, n, "rules").filter((r) => !(r.collection === "*" && !r.taxonomy && !r.term));
|
|
748
|
+
if (rule) rules.push(rule);
|
|
749
|
+
out.rules = rules;
|
|
750
|
+
out.combine = values.combine === "any" ? "any" : "all";
|
|
751
|
+
}
|
|
752
|
+
if (meterForm) {
|
|
753
|
+
out.meter = meter;
|
|
754
|
+
out.join = href ? { href, ...(label ? { label } : {}) } : null;
|
|
755
|
+
}
|
|
756
|
+
return out;
|
|
622
757
|
});
|
|
623
|
-
return ok("site", "Site rules saved.");
|
|
758
|
+
return ok("site", ruleForm && !meterForm ? "Site default saved." : meterForm && !ruleForm ? "Meter and join link saved." : "Site rules saved.");
|
|
624
759
|
}
|
|
625
760
|
|
|
626
761
|
case "type_save": {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The access map: for each kind of reader (visitors, any signed-in member,
|
|
3
|
+
* each level) what the site's settings give them, and through what. It reads
|
|
4
|
+
* only the settings (levels, features, rules, entry rules, folders), so it
|
|
5
|
+
* says which rules name a level, not which rule wins on a given post; the
|
|
6
|
+
* Test tab answers that.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { closure } from "./access.js";
|
|
10
|
+
import type { FeatureConfig, LevelConfig, RedeemIDConfig } from "./config.js";
|
|
11
|
+
import type { EntryRule } from "./settings.js";
|
|
12
|
+
|
|
13
|
+
/** Where staff change an item: a tab on the Access page, a level or feature dialog, or an entry's editor. */
|
|
14
|
+
export type MapGo = { tab: "levels" | "features" | "site" | "types" | "taxonomies" | "files" } | { level: string } | { feature: string } | { entry: { collection: string; id: string } };
|
|
15
|
+
|
|
16
|
+
export type MapKind = "feature" | "content" | "term" | "entry" | "folder";
|
|
17
|
+
|
|
18
|
+
export interface MapItem {
|
|
19
|
+
kind: MapKind;
|
|
20
|
+
label: string;
|
|
21
|
+
/** Extra fact, such as "drip 7 days" or "any content type". */
|
|
22
|
+
note?: string;
|
|
23
|
+
/** The level, feature or "Signed in" it comes through, when not held directly. */
|
|
24
|
+
via?: string;
|
|
25
|
+
go: MapGo;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface MapRow {
|
|
29
|
+
/** "everyone", "signed-in" or the level's slug. */
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
/** Who is in it: tags, or a sentence for the two built-in rows. */
|
|
33
|
+
who: string;
|
|
34
|
+
/** Members holding it now (levels only, when counted). */
|
|
35
|
+
members?: number;
|
|
36
|
+
/** Levels it includes, by name. */
|
|
37
|
+
includes: string[];
|
|
38
|
+
/** Ids of every row this one also gets (itself not included). */
|
|
39
|
+
chain: string[];
|
|
40
|
+
items: MapItem[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface AccessMapInput {
|
|
44
|
+
config: RedeemIDConfig;
|
|
45
|
+
entries: EntryRule[];
|
|
46
|
+
collections: Array<{ slug: string; label: string }>;
|
|
47
|
+
taxonomies: Array<{ name: string; label: string }>;
|
|
48
|
+
/** Tag words for a level, as the Levels table shows them. */
|
|
49
|
+
tagWords(level: LevelConfig): string;
|
|
50
|
+
/** Members holding each level (including through includes). */
|
|
51
|
+
holders?: Record<string, number>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the map. Levels come out with the ones others include first. */
|
|
55
|
+
export function accessMap(input: AccessMapInput): MapRow[] {
|
|
56
|
+
const { config, entries } = input;
|
|
57
|
+
const levels = config.levels ?? [];
|
|
58
|
+
const features = config.features ?? [];
|
|
59
|
+
const rules = config.rules ?? [];
|
|
60
|
+
const files = config.files ?? [];
|
|
61
|
+
const typeName = (slug: string) => (slug === "*" ? "any content type" : (input.collections.find((c) => c.slug === slug)?.label ?? slug));
|
|
62
|
+
const taxName = (name: string) => input.taxonomies.find((t) => t.name === name)?.label ?? name;
|
|
63
|
+
const levelName = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
|
|
64
|
+
|
|
65
|
+
// What names each reader directly: "everyone", "signed-in" or a level slug.
|
|
66
|
+
const direct = new Map<string, MapItem[]>();
|
|
67
|
+
const add = (who: string, item: MapItem) => direct.set(who, [...(direct.get(who) ?? []), item]);
|
|
68
|
+
const whoOf = (open: boolean | undefined, list: string[]) => (open ? ["everyone"] : list.length ? list : ["signed-in"]);
|
|
69
|
+
|
|
70
|
+
for (const r of rules) {
|
|
71
|
+
const drip = r.drip ? `drip ${r.drip} days` : undefined;
|
|
72
|
+
if (r.taxonomy && r.term) {
|
|
73
|
+
const item: MapItem = { kind: "term", label: `${taxName(r.taxonomy)}: ${r.term}`, note: [typeName(r.collection), drip].filter(Boolean).join(", "), go: { tab: "taxonomies" } };
|
|
74
|
+
for (const who of whoOf(r.open, r.levels)) add(who, item);
|
|
75
|
+
} else if (r.collection === "*") {
|
|
76
|
+
for (const who of whoOf(r.open, r.levels)) add(who, { kind: "content", label: "Site default", note: ["everything without its own rule", drip].filter(Boolean).join(", "), go: { tab: "site" } });
|
|
77
|
+
} else {
|
|
78
|
+
for (const who of whoOf(r.open, r.levels)) add(who, { kind: "content", label: typeName(r.collection), note: drip, go: { tab: "types" } });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const e of entries) {
|
|
82
|
+
if (e.mode === "inherit") continue;
|
|
83
|
+
const note = [typeName(e.collection), e.mode === "add" ? "on top of the site's rules" : "", e.drip ? `drip ${e.drip} days` : ""].filter(Boolean).join(", ");
|
|
84
|
+
const item: MapItem = { kind: "entry", label: e.slug || e.entryId, note, go: { entry: { collection: e.collection, id: e.entryId } } };
|
|
85
|
+
for (const who of whoOf(e.mode === "open", e.levels)) add(who, item);
|
|
86
|
+
}
|
|
87
|
+
const featureFolders = new Map<string, MapItem[]>();
|
|
88
|
+
for (const f of files) {
|
|
89
|
+
const item: MapItem = { kind: "folder", label: f.folder, note: f.entries ? "also readers of entries that use its files" : undefined, go: { tab: "files" } };
|
|
90
|
+
if (f.levels) for (const who of f.levels.length ? f.levels : ["signed-in"]) add(who, item);
|
|
91
|
+
for (const slug of f.features ?? []) featureFolders.set(slug, [...(featureFolders.get(slug) ?? []), item]);
|
|
92
|
+
}
|
|
93
|
+
const featureItem = (f: FeatureConfig): MapItem => ({ kind: "feature", label: f.name, note: f.tags?.length ? `also tag ${f.tags.join(", ")}` : undefined, go: { feature: f.slug } });
|
|
94
|
+
for (const f of features) for (const slug of f.levels ?? []) add(slug, featureItem(f));
|
|
95
|
+
|
|
96
|
+
// Everything a reader gets: its own items, then each included level's (marked "via").
|
|
97
|
+
const gather = (ids: Array<{ id: string; via?: string }>): MapItem[] => {
|
|
98
|
+
const out: MapItem[] = [];
|
|
99
|
+
const seen = new Set<string>();
|
|
100
|
+
const push = (item: MapItem, via?: string) => {
|
|
101
|
+
const key = `${item.kind}:${item.label}:${item.note ?? ""}`;
|
|
102
|
+
if (seen.has(key)) return;
|
|
103
|
+
seen.add(key);
|
|
104
|
+
out.push(via ? { ...item, via } : item);
|
|
105
|
+
};
|
|
106
|
+
for (const { id, via } of ids) {
|
|
107
|
+
for (const item of direct.get(id) ?? []) {
|
|
108
|
+
push(item, via);
|
|
109
|
+
// A folder opened to a feature this reader holds.
|
|
110
|
+
if (item.kind === "feature") {
|
|
111
|
+
const slug = (item.go as { feature: string }).feature;
|
|
112
|
+
for (const folder of featureFolders.get(slug) ?? []) push(folder, via ?? item.label);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const ordered = [...levels].sort((a, b) => closure(a.slug, levels).length - closure(b.slug, levels).length);
|
|
120
|
+
const rows: MapRow[] = [
|
|
121
|
+
{ id: "everyone", name: "Everyone", who: "Visitors and members alike", includes: [], chain: [], items: gather([{ id: "everyone" }]) },
|
|
122
|
+
{ id: "signed-in", name: "Signed in", who: "Any member who signs in, whatever their tags", includes: [], chain: ["everyone"], items: gather([{ id: "signed-in" }, { id: "everyone", via: "Everyone" }]) },
|
|
123
|
+
];
|
|
124
|
+
for (const l of ordered) {
|
|
125
|
+
const chain = closure(l.slug, levels);
|
|
126
|
+
rows.push({
|
|
127
|
+
id: l.slug,
|
|
128
|
+
name: l.name,
|
|
129
|
+
who: input.tagWords(l),
|
|
130
|
+
members: input.holders?.[l.slug],
|
|
131
|
+
includes: (l.includes ?? []).map(levelName),
|
|
132
|
+
chain: [...chain.slice(1), "signed-in", "everyone"],
|
|
133
|
+
items: gather([...chain.map((slug, i) => ({ id: slug, via: i ? levelName(slug) : undefined })), { id: "signed-in", via: "Signed in" }, { id: "everyone", via: "Everyone" }]),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
return rows;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** How many items of one kind a row holds, and how many of those are its own. */
|
|
140
|
+
export function tally(row: MapRow, kind: MapKind): { all: number; own: number } {
|
|
141
|
+
const of = row.items.filter((i) => i.kind === kind);
|
|
142
|
+
return { all: of.length, own: of.filter((i) => !i.via).length };
|
|
143
|
+
}
|