@drawnagency/primitives 0.1.104 → 0.1.106

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 (104) hide show
  1. package/dist/auth/cookies.d.ts +21 -1
  2. package/dist/auth/cookies.d.ts.map +1 -1
  3. package/dist/auth/index.d.ts +2 -1
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +3 -1
  6. package/dist/auth/types.d.ts +28 -0
  7. package/dist/auth/types.d.ts.map +1 -1
  8. package/dist/{chunk-PTOLUMGV.js → chunk-4RNF2DRG.js} +1 -0
  9. package/dist/{chunk-MUTHF2GT.js → chunk-5IZ2KTMU.js} +2 -2
  10. package/dist/{chunk-LZKVV4K2.js → chunk-6P4CJCXB.js} +7 -1
  11. package/dist/{chunk-CZLYECHC.js → chunk-AB3XVXI3.js} +4 -4
  12. package/dist/chunk-B7VBSE5X.js +12 -0
  13. package/dist/{chunk-BGHWUJFV.js → chunk-BOZUEKWL.js} +107 -171
  14. package/dist/{chunk-TGNS7D4F.js → chunk-CPTFFLK2.js} +1 -1
  15. package/dist/{chunk-26ALJO4K.js → chunk-D6TLUVOP.js} +1 -1
  16. package/dist/{chunk-EM5DFHDZ.js → chunk-GEENI3OC.js} +13 -4
  17. package/dist/chunk-GGXZZ77Z.js +70 -0
  18. package/dist/{chunk-EWNDBKYK.js → chunk-LUAI5DWR.js} +1 -1
  19. package/dist/chunk-NLQ5F3RQ.js +43 -0
  20. package/dist/{chunk-X4PRBGB3.js → chunk-QLBHERK6.js} +9 -9
  21. package/dist/{chunk-3COSXTOY.js → chunk-XBYJQ5RS.js} +1 -1
  22. package/dist/{chunk-CXQWLG6X.js → chunk-Z7VXESB6.js} +18 -18
  23. package/dist/{chunk-WA6QPDJI.js → chunk-ZHHJVKU7.js} +6 -6
  24. package/dist/collab/rebase.js +2 -1
  25. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  26. package/dist/components/chat/ChatWidget.d.ts.map +1 -1
  27. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -1
  28. package/dist/components/docfile/DocFilePane.d.ts +4 -3
  29. package/dist/components/docfile/DocFilePane.d.ts.map +1 -1
  30. package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -1
  31. package/dist/components/editor/index.js +8 -7
  32. package/dist/components/primitives/index.js +7 -6
  33. package/dist/components/sections/brand-guide.js +8 -7
  34. package/dist/components/sections/register-schemas.js +13 -12
  35. package/dist/components/sections/register.js +13 -12
  36. package/dist/components/shared/Navigation.d.ts +16 -1
  37. package/dist/components/shared/Navigation.d.ts.map +1 -1
  38. package/dist/components/shared/SectionSummaryRow.d.ts +24 -0
  39. package/dist/components/shared/SectionSummaryRow.d.ts.map +1 -0
  40. package/dist/components/shell/AudienceAddForm.d.ts +4 -1
  41. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  42. package/dist/components/shell/AudienceDetailsPanel.d.ts +28 -0
  43. package/dist/components/shell/AudienceDetailsPanel.d.ts.map +1 -0
  44. package/dist/components/shell/AudienceRow.d.ts +15 -1
  45. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  46. package/dist/components/shell/CredentialList.d.ts +18 -0
  47. package/dist/components/shell/CredentialList.d.ts.map +1 -0
  48. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  49. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  50. package/dist/components/shell/ViewerLoginForm.d.ts +5 -1
  51. package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
  52. package/dist/index.js +47 -29
  53. package/dist/lib/audience-overview.d.ts +53 -0
  54. package/dist/lib/audience-overview.d.ts.map +1 -0
  55. package/dist/lib/audience-secret.d.ts +11 -0
  56. package/dist/lib/audience-secret.d.ts.map +1 -0
  57. package/dist/lib/audience-secret.js +70 -0
  58. package/dist/lib/events.d.ts +3 -0
  59. package/dist/lib/events.d.ts.map +1 -1
  60. package/dist/lib/index-ops.js +3 -2
  61. package/dist/lib/index.js +13 -12
  62. package/dist/lib/nav.d.ts.map +1 -1
  63. package/dist/lib/thumb-views.d.ts +17 -0
  64. package/dist/lib/thumb-views.d.ts.map +1 -0
  65. package/dist/media/index.js +4 -3
  66. package/dist/schemas/audience.d.ts +29 -0
  67. package/dist/schemas/audience.d.ts.map +1 -1
  68. package/dist/schemas/audience.js +24 -0
  69. package/dist/schemas/auth.d.ts +1 -0
  70. package/dist/schemas/auth.d.ts.map +1 -1
  71. package/dist/schemas/auth.js +1 -1
  72. package/dist/schemas/index.js +28 -12
  73. package/dist/schemas/site-config.d.ts +1 -1
  74. package/dist/schemas/site-config.d.ts.map +1 -1
  75. package/dist/types/database.d.ts +59 -5
  76. package/dist/types/database.d.ts.map +1 -1
  77. package/package.json +9 -1
  78. package/src/auth/cookies.ts +37 -4
  79. package/src/auth/index.ts +2 -1
  80. package/src/auth/types.ts +32 -0
  81. package/src/components/chat/ChatPanel.tsx +4 -2
  82. package/src/components/chat/ChatWidget.tsx +4 -1
  83. package/src/components/docfile/DocFileOverlay.tsx +28 -2
  84. package/src/components/docfile/DocFilePane.tsx +4 -3
  85. package/src/components/editor/SectionOrderingModal.tsx +8 -73
  86. package/src/components/shared/Navigation.tsx +113 -7
  87. package/src/components/shared/SectionSummaryRow.tsx +73 -0
  88. package/src/components/shell/AudienceAddForm.tsx +18 -9
  89. package/src/components/shell/AudienceDetailsPanel.tsx +263 -0
  90. package/src/components/shell/AudienceRow.tsx +55 -13
  91. package/src/components/shell/CredentialList.tsx +285 -0
  92. package/src/components/shell/EditorShell.tsx +94 -4
  93. package/src/components/shell/SiteSettingsViewerAccess.tsx +57 -6
  94. package/src/components/shell/ViewerLoginForm.tsx +35 -15
  95. package/src/lib/audience-overview.ts +110 -0
  96. package/src/lib/audience-secret.ts +99 -0
  97. package/src/lib/events.ts +3 -0
  98. package/src/lib/nav.ts +6 -2
  99. package/src/lib/thumb-views.ts +28 -0
  100. package/src/schemas/audience.ts +52 -0
  101. package/src/schemas/auth.ts +6 -0
  102. package/src/schemas/site-config.ts +1 -1
  103. package/src/types/database.ts +59 -5
  104. package/dist/chunk-ZDQ3TT47.js +0 -30
@@ -93,7 +93,11 @@ import { generateSiteNav } from "../../lib/nav";
93
93
  import { deriveSiteDocuments, type SiteDocument } from "../../lib/documents";
94
94
  import { setSiteDocuments, closeDocFile } from "../docfile/doc-file-state";
95
95
  import { pagesInNavOrder, moveNavEntry, addGroup, renameGroup, setGroupStartCollapsed, deleteGroup, type NavLocation } from "../../lib/nav-tree";
96
- import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent } from "../../lib/events";
96
+ import { siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, historyRestoreEvent, historyRestoreFailedEvent, editorDirtyEvent, audienceDetailsEvent } from "../../lib/events";
97
+ import { AudienceDetailsPanel, type AudienceDetail } from "./AudienceDetailsPanel";
98
+ import { buildAudienceOverview, type SectionDisplay } from "../../lib/audience-overview";
99
+ import { toThumbViews } from "../../lib/thumb-views";
100
+ import type { ViewerCredential } from "../../schemas/audience";
97
101
  import {
98
102
  homePage, pageById, pageBySlug, pageDisplayTitle, addSectionToPage, removeSectionFromPages,
99
103
  reorderVisibleSectionInPage, audiencePasses,
@@ -263,6 +267,13 @@ export default function EditorShell({
263
267
  // fully landed when part of it was refused.
264
268
  const [saveWarnings, setSaveWarnings] = useState<string[]>([]);
265
269
  const [activePageId, setActivePageId] = useState<string | null>(null);
270
+ // The Audience Details surface, opened from the nav. Rendered INSTEAD of the
271
+ // section canvas so the toolbar, unsaved drafts and modals all stay mounted —
272
+ // an editor reviewing access never leaves the editor.
273
+ const [showAudienceDetails, setShowAudienceDetails] = useState(false);
274
+ // null = the auth provider has no named sign-ins (`.env` mode → route 501s),
275
+ // which is also what turns the reveal buttons off.
276
+ const [viewerCredentials, setViewerCredentials] = useState<ViewerCredential[] | null>(null);
266
277
  const [movingSectionId, setMovingSectionId] = useState<string | null>(null);
267
278
  // Set by recoverConflict when a rebase surfaces overlapping edits; drives the
268
279
  // keep-mine/take-theirs dialog. `resolve` is the promise resolver recovery
@@ -369,6 +380,41 @@ export default function EditorShell({
369
380
  return resolvedActivePage.order.map((id) => sectionsById[id]).filter((s): s is LoadedSection => !!s);
370
381
  }, [resolvedActivePage, sectionsById]);
371
382
 
383
+ // Same model the /audiences route builds server-side, from the shell's own
384
+ // live state — so an editor sees their SAVED draft's access, not the published
385
+ // snapshot the route would show.
386
+ const audienceDetails = useMemo<AudienceDetail[]>(() => {
387
+ if (!showAudienceDetails) return [];
388
+ const cache = new Map<string, SectionDisplay>();
389
+ const display = (sectionId: string): SectionDisplay => {
390
+ const cached = cache.get(sectionId);
391
+ if (cached) return cached;
392
+ const loaded = sectionsById[sectionId];
393
+ const type = loaded?.section.type ?? siteIndex.sections[sectionId]?.type ?? "unknown";
394
+ const definition = getSection(type);
395
+ const resolved: SectionDisplay = {
396
+ label: loaded
397
+ ? definition?.getLabel?.(loaded.section) ?? definition?.label ?? type
398
+ : definition?.label ?? type,
399
+ typeLabel: definition?.label ?? type,
400
+ thumbnails: loaded ? toThumbViews(definition?.getThumbnails?.(loaded.section) ?? [], mediaManifest) : [],
401
+ };
402
+ cache.set(sectionId, resolved);
403
+ return resolved;
404
+ };
405
+ return audiences.map((a) => ({
406
+ name: a.name,
407
+ displayName: a.displayName,
408
+ color: a.color,
409
+ isDefault: a.isDefault,
410
+ hasPassword: a.hasPassword,
411
+ credentials: (viewerCredentials ?? [])
412
+ .filter((c) => c.audienceName === a.name)
413
+ .map((c) => ({ username: c.username, label: c.label })),
414
+ overview: buildAudienceOverview(siteIndex, display, a.name),
415
+ }));
416
+ }, [showAudienceDetails, audiences, viewerCredentials, siteIndex, sectionsById, mediaManifest]);
417
+
372
418
  const pageAudiences = useMemo(() => {
373
419
  if (!resolvedActivePage || resolvedActivePage.access.length === 0) return audiences;
374
420
  return audiences.filter((a) => resolvedActivePage.access.includes(a.name));
@@ -965,11 +1011,16 @@ export default function EditorShell({
965
1011
  useEffect(() => {
966
1012
  if (!resolvedActivePage) return;
967
1013
  const nav = generateSiteNav({
968
- index: siteIndex, activeSections, activePageId: resolvedActivePage.id, mode: "editor", audience: null,
1014
+ index: siteIndex, activeSections,
1015
+ // The Audience Details surface is not a page: clearing the active id drops
1016
+ // the previous page's highlight (and its heading tree), so the sidebar
1017
+ // never shows you in two places at once.
1018
+ activePageId: showAudienceDetails ? null : resolvedActivePage.id,
1019
+ mode: "editor", audience: null,
969
1020
  documents: siteDocuments ?? [],
970
1021
  });
971
1022
  siteNavChangeEvent.dispatch(nav);
972
- }, [siteIndex, activeSections, resolvedActivePage, siteDocuments]);
1023
+ }, [siteIndex, activeSections, resolvedActivePage, siteDocuments, showAudienceDetails]);
973
1024
 
974
1025
  const goToPage = useCallback((pageId: string) => {
975
1026
  const page = pageById(siteIndexRef.current, pageId);
@@ -986,7 +1037,25 @@ export default function EditorShell({
986
1037
  }, []);
987
1038
 
988
1039
  // Nav island → shell page selection.
989
- useEffect(() => pageSelectEvent.listen((id) => goToPage(id)), [goToPage]);
1040
+ useEffect(() => pageSelectEvent.listen((id) => { setShowAudienceDetails(false); goToPage(id); }), [goToPage]);
1041
+ useEffect(() => audienceDetailsEvent.listen(() => setShowAudienceDetails(true)), []);
1042
+
1043
+ // Fetched on open rather than at mount: most editing sessions never look at
1044
+ // this, and the list changes whenever Site Settings adds or removes a sign-in.
1045
+ useEffect(() => {
1046
+ if (!showAudienceDetails) return;
1047
+ let cancelled = false;
1048
+ (async () => {
1049
+ try {
1050
+ const res = await fetch("/api/auth/credentials");
1051
+ if (cancelled) return;
1052
+ setViewerCredentials(res.ok ? (await res.json()).credentials ?? [] : null);
1053
+ } catch {
1054
+ if (!cancelled) setViewerCredentials(null);
1055
+ }
1056
+ })();
1057
+ return () => { cancelled = true; };
1058
+ }, [showAudienceDetails]);
990
1059
 
991
1060
  // Back/forward maps the URL back to the active page (no reload).
992
1061
  useEffect(() => {
@@ -1879,6 +1948,26 @@ export default function EditorShell({
1879
1948
  document pane) lives here for exactly the same reason: hiding
1880
1949
  main would hide the toolbar and FABs too. */}
1881
1950
  <div className="bp-band-shift bp-page-content">
1951
+ {showAudienceDetails ? (
1952
+ <div className="py-8">
1953
+ <div className="flex flex-wrap items-baseline justify-between gap-3">
1954
+ <h1 className="text-2xl font-bold text-base-contrast">Audience details</h1>
1955
+ <Button
1956
+ variant="secondary"
1957
+ onClick={() => resolvedActivePage && pageSelectEvent.dispatch(resolvedActivePage.id)}
1958
+ >
1959
+ Back to editing
1960
+ </Button>
1961
+ </div>
1962
+ <p className="mt-2 max-w-2xl text-sm text-base-contrast-light">
1963
+ What each audience can see, and the sign-ins that reach it. Reflects your
1964
+ current working copy — add or remove sign-ins under Site settings → Audiences.
1965
+ </p>
1966
+ <div className="mt-6">
1967
+ <AudienceDetailsPanel audiences={audienceDetails} revealSupported={viewerCredentials !== null} />
1968
+ </div>
1969
+ </div>
1970
+ ) : (
1882
1971
  <HistoryOrEditorContent
1883
1972
  index={siteIndex}
1884
1973
  activeSections={activeSections}
@@ -1907,6 +1996,7 @@ export default function EditorShell({
1907
1996
  onTakeover={confirmTakeover}
1908
1997
  />
1909
1998
  </HistoryOrEditorContent>
1999
+ )}
1910
2000
  </div>
1911
2001
  <GlobalModal />
1912
2002
  <SiteSettingsModal
@@ -4,6 +4,7 @@ import { Button } from "../shared/Button";
4
4
  import { Toggle } from "../shared/Toggle";
5
5
  import { AudienceRow } from "./AudienceRow";
6
6
  import { AudienceAddForm } from "./AudienceAddForm";
7
+ import type { ViewerCredential } from "../../schemas/audience";
7
8
  import { cn } from "../../lib/cn";
8
9
 
9
10
  interface Props {
@@ -12,8 +13,18 @@ interface Props {
12
13
  onAudiencesChange: (audiences: Audience[]) => void;
13
14
  }
14
15
 
16
+ /** 32 random hex chars — long enough that it can never be guessed or typed. */
17
+ function unusablePassword(): string {
18
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
19
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
20
+ }
21
+
15
22
  export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, onAudiencesChange }: Props) {
16
23
  const [audiences, setAudiences] = useState<Audience[]>([]);
24
+ // Null until we know: undefined-vs-empty matters, because an empty ARRAY means
25
+ // credential mode with no sign-ins yet, while null means this site has no
26
+ // credential support at all and keeps its audience-level password fields.
27
+ const [credentials, setCredentials] = useState<ViewerCredential[] | null>(null);
17
28
  const [passwordEnabled, setPasswordEnabled] = useState(true);
18
29
  const [loading, setLoading] = useState(true);
19
30
  const [showAddForm, setShowAddForm] = useState(false);
@@ -21,12 +32,17 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
21
32
 
22
33
  const loadAudiences = useCallback(async () => {
23
34
  try {
24
- const res = await fetch("/api/auth/audiences");
25
- const data = await res.json();
35
+ const [audienceRes, credentialRes] = await Promise.all([
36
+ fetch("/api/auth/audiences"),
37
+ fetch("/api/auth/credentials"),
38
+ ]);
39
+ const data = await audienceRes.json();
26
40
  const list = data.audiences ?? [];
27
41
  setAudiences(list);
28
42
  onAudiencesChange(list);
29
43
  setPasswordEnabled(data.passwordEnabled ?? true);
44
+ // 501 = no credential support (`.env` mode) — stay in audience-password UI.
45
+ setCredentials(credentialRes.ok ? (await credentialRes.json()).credentials ?? [] : null);
30
46
  } finally {
31
47
  setLoading(false);
32
48
  }
@@ -58,10 +74,15 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
58
74
  }
59
75
 
60
76
  async function handleAdd(data: { displayName: string; color: string | null; password: string }) {
77
+ // In credential mode the audience-level password is vestigial but the column
78
+ // is NOT NULL, so it gets an unguessable value nobody is ever told. Writing
79
+ // an empty one would leave the audience walk-in-able through the legacy
80
+ // dropdown login if this site were ever rolled back to an older deployment.
81
+ const password = data.password.length > 0 ? data.password : unusablePassword();
61
82
  const res = await fetch("/api/auth/audiences", {
62
83
  method: "POST",
63
84
  headers: { "Content-Type": "application/json" },
64
- body: JSON.stringify(data),
85
+ body: JSON.stringify({ ...data, password }),
65
86
  });
66
87
  if (!res.ok) {
67
88
  const err = await res.json().catch(() => ({}));
@@ -87,6 +108,31 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
87
108
  await loadAudiences();
88
109
  }
89
110
 
111
+ async function credentialRequest(method: string, body: unknown) {
112
+ const res = await fetch("/api/auth/credentials", {
113
+ method,
114
+ headers: { "Content-Type": "application/json" },
115
+ body: JSON.stringify(body),
116
+ });
117
+ if (!res.ok) {
118
+ const err = await res.json().catch(() => ({}));
119
+ throw new Error(err.error ?? "Request failed");
120
+ }
121
+ await loadAudiences();
122
+ }
123
+
124
+ function credentialActions(audienceName: string) {
125
+ return {
126
+ create: (d: { username: string; label: string | null; password: string }) =>
127
+ credentialRequest("POST", { ...d, audienceName }),
128
+ setPassword: (username: string, password: string) =>
129
+ credentialRequest("PATCH", { username, password }),
130
+ setLabel: (username: string, label: string | null) =>
131
+ credentialRequest("PATCH", { username, label }),
132
+ delete: (username: string) => credentialRequest("DELETE", { username }),
133
+ };
134
+ }
135
+
90
136
  async function handleDelete(name: string) {
91
137
  const res = await fetch("/api/auth/audiences", {
92
138
  method: "DELETE",
@@ -124,9 +170,11 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
124
170
  <span className="text-sm font-medium">Require password to view site</span>
125
171
  </div>
126
172
  <p className="text-xs text-base-contrast-light">
127
- {passwordEnabled
128
- ? "Viewers must select an audience and enter a password to see any content."
129
- : "Site is fully public. All published sections are visible to anyone."}
173
+ {!passwordEnabled
174
+ ? "Site is fully public. All published sections are visible to anyone."
175
+ : credentials
176
+ ? "Viewers must sign in with a username and password to see any content."
177
+ : "Viewers must select an audience and enter a password to see any content."}
130
178
  </p>
131
179
  {toggleError && <p className="text-xs text-red-600">{toggleError}</p>}
132
180
  </div>
@@ -147,6 +195,8 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
147
195
  canMutate={audienceManagement}
148
196
  onSave={(patch) => handleSave(a.name, patch)}
149
197
  onDelete={() => handleDelete(a.name)}
198
+ credentials={credentials ? credentials.filter((c) => c.audienceName === a.name) : undefined}
199
+ credentialActions={credentials ? credentialActions(a.name) : undefined}
150
200
  />
151
201
  ))}
152
202
  {audiences.length === 0 && (
@@ -159,6 +209,7 @@ export function SiteSettingsViewerAccess({ audienceManagement, passwordToggle, o
159
209
  <AudienceAddForm
160
210
  onSubmit={handleAdd}
161
211
  onCancel={() => setShowAddForm(false)}
212
+ requirePassword={credentials === null}
162
213
  />
163
214
  ) : (
164
215
  <Button variant="secondary" onClick={() => setShowAddForm(true)}>
@@ -1,16 +1,25 @@
1
1
  import { useState, type FormEvent } from "react";
2
+ import { Input } from "../shared/Input";
2
3
  import { PasswordInput } from "../shared/PasswordInput";
3
4
  import { Button } from "../shared/Button";
4
5
  import { Select } from "../shared/Select";
5
6
  import { safeRedirect } from "../../lib/safeRedirect";
6
7
 
7
8
  interface Props {
9
+ /** Audience mode only. Empty (and unused) in credential mode — the page must
10
+ * not ship an audience list to a login form that doesn't offer the choice. */
8
11
  audiences: Array<{ name: string; displayName: string; hasPassword?: boolean }>;
12
+ /** Set by the page from the auth provider's capability, never by the client. */
13
+ mode?: "audience" | "credentials";
9
14
  next?: string | null;
10
15
  }
11
16
 
12
- export function ViewerLoginForm({ audiences, next }: Props) {
13
- const [audience, setAudience] = useState(audiences.length === 1 ? audiences[0].name : "");
17
+ export function ViewerLoginForm({ audiences, mode = "audience", next }: Props) {
18
+ const credentialMode = mode === "credentials";
19
+ const [username, setUsername] = useState("");
20
+ const [audience, setAudience] = useState(
21
+ !credentialMode && audiences.length === 1 ? audiences[0].name : "",
22
+ );
14
23
  const [password, setPassword] = useState("");
15
24
  const [error, setError] = useState<string | null>(null);
16
25
  const [loading, setLoading] = useState(false);
@@ -22,19 +31,19 @@ export function ViewerLoginForm({ audiences, next }: Props) {
22
31
 
23
32
  // An open (passwordless) audience needs no password field — enter with one click.
24
33
  // Default to requiring a password until an audience is chosen so the field
25
- // doesn't flash for the "Select audience..." state.
34
+ // doesn't flash for the "Select audience..." state. Credential mode always
35
+ // takes a password.
26
36
  const selected = audiences.find((a) => a.name === audience);
27
- const needsPassword = selected ? selected.hasPassword !== false : true;
37
+ const needsPassword = credentialMode || (selected ? selected.hasPassword !== false : true);
28
38
 
29
39
  async function handleSubmit(e: FormEvent) {
30
40
  e.preventDefault();
31
41
  setLoading(true);
32
42
  setError(null);
33
43
 
34
- const body: { audience: string; password: string; next?: string } = {
35
- audience,
36
- password: needsPassword ? password : "",
37
- };
44
+ const body: { audience?: string; username?: string; password: string; next?: string } = credentialMode
45
+ ? { username: username.trim().toLowerCase(), password }
46
+ : { audience, password: needsPassword ? password : "" };
38
47
  if (next) body.next = next;
39
48
 
40
49
  try {
@@ -62,12 +71,23 @@ export function ViewerLoginForm({ audiences, next }: Props) {
62
71
  return (
63
72
  <div className="mx-auto max-w-sm">
64
73
  <form onSubmit={handleSubmit} className="space-y-4">
65
- <Select
66
- label="Audience"
67
- value={audience}
68
- onChange={setAudience}
69
- options={audienceOptions}
70
- />
74
+ {credentialMode ? (
75
+ <Input
76
+ label="Username"
77
+ value={username}
78
+ onChange={setUsername}
79
+ autoComplete="username"
80
+ autoCapitalize="none"
81
+ spellCheck={false}
82
+ />
83
+ ) : (
84
+ <Select
85
+ label="Audience"
86
+ value={audience}
87
+ onChange={setAudience}
88
+ options={audienceOptions}
89
+ />
90
+ )}
71
91
 
72
92
  {needsPassword && (
73
93
  <PasswordInput
@@ -86,7 +106,7 @@ export function ViewerLoginForm({ audiences, next }: Props) {
86
106
  type="submit"
87
107
  variant="brand"
88
108
  size="md"
89
- disabled={loading || !audience}
109
+ disabled={loading || (credentialMode ? username.trim().length === 0 : !audience)}
90
110
  isLoading={loading}
91
111
  loadingLabel="Verifying..."
92
112
  className="w-full"
@@ -0,0 +1,110 @@
1
+ /**
2
+ * What each audience can actually see. Powers the Audience Details page, which
3
+ * answers "if I give a partner this password, what do they get?".
4
+ *
5
+ * Pure and view-agnostic: section labels/thumbnails are resolved by the caller
6
+ * (they need the section registry and the media provider) and passed in, so this
7
+ * stays trivially testable and ships no registry into viewer JS.
8
+ */
9
+ import type { SiteIndex } from "../schemas/site-config";
10
+ import { audiencePasses, pageDisplayTitle } from "./pages";
11
+ import type { ThumbView } from "./thumb-views";
12
+
13
+ /** Caller-resolved presentation for one section. */
14
+ export interface SectionDisplay {
15
+ label: string;
16
+ typeLabel: string;
17
+ thumbnails: ThumbView[];
18
+ }
19
+
20
+ export interface OverviewSection extends SectionDisplay {
21
+ id: string;
22
+ available: boolean;
23
+ /** Live-but-draft sections are invisible to EVERY audience, not just this one. */
24
+ draft: boolean;
25
+ }
26
+
27
+ export interface OverviewPage {
28
+ id: string;
29
+ title: string;
30
+ slug: string;
31
+ href: string;
32
+ /** Live but off the sidebar — reachable by URL, so it counts as available. */
33
+ hiddenFromNav: boolean;
34
+ /** none = the page itself is gated away; partial = page in, some sections out. */
35
+ availability: "full" | "partial" | "none";
36
+ sections: OverviewSection[];
37
+ }
38
+
39
+ export interface AudienceOverview {
40
+ audience: string;
41
+ pages: OverviewPage[];
42
+ pagesAvailable: number;
43
+ pagesTotal: number;
44
+ sectionsAvailable: number;
45
+ sectionsTotal: number;
46
+ }
47
+
48
+ /**
49
+ * Availability for one audience across the whole site.
50
+ *
51
+ * Archived pages are excluded outright (nobody can reach them). Draft sections
52
+ * are listed as unavailable but flagged, so a page reading "3 of 8" is not
53
+ * mistaken for an access problem.
54
+ *
55
+ * The cascade matters: a section whose own access allows this audience is still
56
+ * unavailable inside a page that gates them out. Page access wins.
57
+ */
58
+ export function buildAudienceOverview(
59
+ index: SiteIndex,
60
+ display: (sectionId: string) => SectionDisplay,
61
+ audience: string,
62
+ ): AudienceOverview {
63
+ const pages: OverviewPage[] = [];
64
+ let sectionsAvailable = 0;
65
+ let sectionsTotal = 0;
66
+ let pagesAvailable = 0;
67
+
68
+ for (const page of index.pages) {
69
+ if (page.status === "archived") continue;
70
+ const pageOpen = audiencePasses(page.access, audience);
71
+ if (pageOpen) pagesAvailable++;
72
+
73
+ const sections: OverviewSection[] = [];
74
+ for (const id of page.order) {
75
+ const meta = index.sections[id];
76
+ // Not in the index — the loader would drop it too; nothing to report.
77
+ if (!meta) continue;
78
+ if (meta.status === "archived") continue;
79
+ const draft = meta.status !== "live";
80
+ const available = pageOpen && !draft && audiencePasses(meta.access, audience);
81
+ sectionsTotal++;
82
+ if (available) sectionsAvailable++;
83
+ sections.push({ id, available, draft, ...display(id) });
84
+ }
85
+
86
+ const availableHere = sections.filter((s) => s.available).length;
87
+ pages.push({
88
+ id: page.id,
89
+ title: pageDisplayTitle(page.title),
90
+ slug: page.slug,
91
+ href: page.slug === "" ? "/" : `/${page.slug}`,
92
+ hiddenFromNav: !page.showInNav,
93
+ availability: !pageOpen
94
+ ? "none"
95
+ : availableHere === sections.length
96
+ ? "full"
97
+ : "partial",
98
+ sections,
99
+ });
100
+ }
101
+
102
+ return {
103
+ audience,
104
+ pages,
105
+ pagesAvailable,
106
+ pagesTotal: pages.length,
107
+ sectionsAvailable,
108
+ sectionsTotal,
109
+ };
110
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Reversible storage for audience (viewer) passwords, so an editor or the
3
+ * default audience can be SHOWN an existing password rather than only replacing
4
+ * it. `password_hash` is bcrypt and one-way, so a sealed copy is stored beside
5
+ * it and opened on demand.
6
+ *
7
+ * The key is derived from the site's own SESSION_SECRET via HKDF — no new env
8
+ * var, no provisioner change, and (in platform mode) the shared Supabase project
9
+ * stores only a blob it cannot open, because the secret lives in the client
10
+ * site's Netlify env alone.
11
+ *
12
+ * This is envelope encryption with a derived key, NOT a KMS: whoever holds both
13
+ * SESSION_SECRET and the row recovers the password. That is the same secret that
14
+ * already signs every session cookie, and the protected value is a shared
15
+ * viewing password — an accepted bar, stated so it is never mistaken for more.
16
+ *
17
+ * WebCrypto only, no `node:crypto` at module scope: auth-supabase/supabase.ts
18
+ * value-imports this and is browser-reachable via portal.config.mjs (see
19
+ * .claude/rules/dev-server-symlinks.md and the sigv4.ts precedent).
20
+ */
21
+ import { requireSessionSecret } from "../auth/security";
22
+
23
+ /** Domain separation: this key must never coincide with a future HKDF use. */
24
+ const INFO = "drawnagency:audience-password:v1";
25
+ const VERSION = "v1";
26
+ const IV_BYTES = 12;
27
+
28
+ const enc = new TextEncoder();
29
+ const dec = new TextDecoder();
30
+
31
+ function toBase64Url(bytes: Uint8Array): string {
32
+ let binary = "";
33
+ for (const b of bytes) binary += String.fromCharCode(b);
34
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
35
+ }
36
+
37
+ function fromBase64Url(value: string): Uint8Array {
38
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
39
+ const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
40
+ const bytes = new Uint8Array(binary.length);
41
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
42
+ return bytes;
43
+ }
44
+
45
+ // Derived per call rather than memoised: HKDF is cheap next to the bcrypt
46
+ // compare that always accompanies it, and a cache would outlive a rotated
47
+ // SESSION_SECRET within a warm serverless process.
48
+ async function deriveKey(): Promise<CryptoKey> {
49
+ const base = await crypto.subtle.importKey(
50
+ "raw",
51
+ requireSessionSecret() as BufferSource,
52
+ "HKDF",
53
+ false,
54
+ ["deriveKey"],
55
+ );
56
+ return crypto.subtle.deriveKey(
57
+ { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: enc.encode(INFO) },
58
+ base,
59
+ { name: "AES-GCM", length: 256 },
60
+ false,
61
+ ["encrypt", "decrypt"],
62
+ );
63
+ }
64
+
65
+ /** Seal a plaintext password into `v1.<iv>.<ciphertext>` (base64url parts). */
66
+ export async function sealAudiencePassword(password: string): Promise<string> {
67
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
68
+ const ciphertext = await crypto.subtle.encrypt(
69
+ { name: "AES-GCM", iv: iv as BufferSource },
70
+ await deriveKey(),
71
+ enc.encode(password) as BufferSource,
72
+ );
73
+ return `${VERSION}.${toBase64Url(iv)}.${toBase64Url(new Uint8Array(ciphertext))}`;
74
+ }
75
+
76
+ /**
77
+ * Open a sealed password, or null when it cannot be trusted — unknown version,
78
+ * malformed parts, tampered ciphertext (GCM auth failure), or a rotated
79
+ * SESSION_SECRET. Callers treat null as "not revealable" and never as an error;
80
+ * they must ALSO verify the result against the bcrypt hash, since a valid seal
81
+ * can still be stale (a password changed by a writer that skipped the cipher).
82
+ */
83
+ export async function openAudiencePassword(sealed: string | null | undefined): Promise<string | null> {
84
+ if (!sealed) return null;
85
+ const parts = sealed.split(".");
86
+ if (parts.length !== 3 || parts[0] !== VERSION) return null;
87
+ try {
88
+ const iv = fromBase64Url(parts[1]);
89
+ if (iv.byteLength !== IV_BYTES) return null;
90
+ const plain = await crypto.subtle.decrypt(
91
+ { name: "AES-GCM", iv: iv as BufferSource },
92
+ await deriveKey(),
93
+ fromBase64Url(parts[2]) as BufferSource,
94
+ );
95
+ return dec.decode(plain);
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
package/src/lib/events.ts CHANGED
@@ -36,3 +36,6 @@ export const historyRestoreFailedEvent = createEvent<{ message: string }>("histo
36
36
  export const editorDirtyEvent = createEvent<{ hasUnsavedChanges: boolean }>("editor-dirty");
37
37
  export const siteNavChangeEvent = createEvent<SiteNav>("sitenavchange-v2");
38
38
  export const pageSelectEvent = createEvent<string>("pageselect");
39
+ /** Nav → EditorShell: open the Audience Details surface inside the editor, so
40
+ * editors keep the toolbar instead of navigating out to the /audiences route. */
41
+ export const audienceDetailsEvent = createEvent<void>("audiencedetailsopen");
package/src/lib/nav.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { LoadedSection } from "./loader";
2
2
  import { getSection, type SectionRegistry } from "./registry";
3
- import { audiencePasses, homePage, pageDisplayTitle } from "./pages";
3
+ import { audiencePasses, pageDisplayTitle } from "./pages";
4
4
  import { navEntries } from "./nav-tree";
5
5
  import type { SiteIndex } from "../schemas/site-config";
6
6
  import type { SiteDocument } from "./documents";
@@ -156,7 +156,11 @@ export function generateSiteNav(opts: {
156
156
  documents?: SiteDocument[];
157
157
  }): SiteNav {
158
158
  const { index, activeSections, activePageId, mode, audience, registry, documents } = opts;
159
- const active = activePageId ?? homePage(index).id;
159
+ // null means NO page is active — not "default to home". Surfaces that are not
160
+ // a page (the Audience Details route and its in-editor twin) rely on that: a
161
+ // page must not keep its highlight while you are somewhere else, or the
162
+ // sidebar claims you are in two places at once.
163
+ const active = activePageId;
160
164
  const byId = new Map(index.pages.map((p) => [p.id, p]));
161
165
 
162
166
  const toNavItem = (p: (typeof index.pages)[number]): PageNavItem => ({
@@ -0,0 +1,28 @@
1
+ import type { Thumbnail } from "./registry";
2
+ import { getMediaProvider } from "../media";
3
+ import type { MediaManifest } from "../media/types";
4
+
5
+ /**
6
+ * A section thumbnail with its media reference already resolved to a URL, so the
7
+ * component rendering it needs neither the registry nor the media provider. The
8
+ * Audience Details page resolves these server-side for exactly that reason.
9
+ */
10
+ export type ThumbView =
11
+ | { kind: "color"; value: string }
12
+ | { kind: "image"; src: string | null; alt: string };
13
+
14
+ function resolveThumbnailSrc(thumb: Thumbnail & { type: "image" }, manifest: MediaManifest): string | null {
15
+ const item = manifest.images[thumb.src];
16
+ if (!item) return null;
17
+ const resolved = getMediaProvider().resolve(item, [200]);
18
+ if (!resolved) return null;
19
+ return resolved.tag === "img" || resolved.tag === "video" ? resolved.src : null;
20
+ }
21
+
22
+ export function toThumbViews(thumbnails: Thumbnail[], manifest: MediaManifest): ThumbView[] {
23
+ return thumbnails.map((thumb) =>
24
+ thumb.type === "color"
25
+ ? { kind: "color", value: thumb.value }
26
+ : { kind: "image", src: resolveThumbnailSrc(thumb, manifest), alt: thumb.alt ?? "" },
27
+ );
28
+ }