@drawnagency/primitives 0.1.105 → 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 (95) 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/editor/SectionOrderingModal.d.ts.map +1 -1
  26. package/dist/components/editor/index.js +8 -7
  27. package/dist/components/primitives/index.js +7 -6
  28. package/dist/components/sections/brand-guide.js +8 -7
  29. package/dist/components/sections/register-schemas.js +13 -12
  30. package/dist/components/sections/register.js +13 -12
  31. package/dist/components/shared/Navigation.d.ts +16 -1
  32. package/dist/components/shared/Navigation.d.ts.map +1 -1
  33. package/dist/components/shared/SectionSummaryRow.d.ts +24 -0
  34. package/dist/components/shared/SectionSummaryRow.d.ts.map +1 -0
  35. package/dist/components/shell/AudienceAddForm.d.ts +4 -1
  36. package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
  37. package/dist/components/shell/AudienceDetailsPanel.d.ts +28 -0
  38. package/dist/components/shell/AudienceDetailsPanel.d.ts.map +1 -0
  39. package/dist/components/shell/AudienceRow.d.ts +15 -1
  40. package/dist/components/shell/AudienceRow.d.ts.map +1 -1
  41. package/dist/components/shell/CredentialList.d.ts +18 -0
  42. package/dist/components/shell/CredentialList.d.ts.map +1 -0
  43. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  44. package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
  45. package/dist/components/shell/ViewerLoginForm.d.ts +5 -1
  46. package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
  47. package/dist/index.js +47 -29
  48. package/dist/lib/audience-overview.d.ts +53 -0
  49. package/dist/lib/audience-overview.d.ts.map +1 -0
  50. package/dist/lib/audience-secret.d.ts +11 -0
  51. package/dist/lib/audience-secret.d.ts.map +1 -0
  52. package/dist/lib/audience-secret.js +70 -0
  53. package/dist/lib/events.d.ts +3 -0
  54. package/dist/lib/events.d.ts.map +1 -1
  55. package/dist/lib/index-ops.js +3 -2
  56. package/dist/lib/index.js +13 -12
  57. package/dist/lib/nav.d.ts.map +1 -1
  58. package/dist/lib/thumb-views.d.ts +17 -0
  59. package/dist/lib/thumb-views.d.ts.map +1 -0
  60. package/dist/media/index.js +4 -3
  61. package/dist/schemas/audience.d.ts +29 -0
  62. package/dist/schemas/audience.d.ts.map +1 -1
  63. package/dist/schemas/audience.js +24 -0
  64. package/dist/schemas/auth.d.ts +1 -0
  65. package/dist/schemas/auth.d.ts.map +1 -1
  66. package/dist/schemas/auth.js +1 -1
  67. package/dist/schemas/index.js +28 -12
  68. package/dist/schemas/site-config.d.ts +1 -1
  69. package/dist/schemas/site-config.d.ts.map +1 -1
  70. package/dist/types/database.d.ts +59 -5
  71. package/dist/types/database.d.ts.map +1 -1
  72. package/package.json +9 -1
  73. package/src/auth/cookies.ts +37 -4
  74. package/src/auth/index.ts +2 -1
  75. package/src/auth/types.ts +32 -0
  76. package/src/components/editor/SectionOrderingModal.tsx +8 -73
  77. package/src/components/shared/Navigation.tsx +113 -7
  78. package/src/components/shared/SectionSummaryRow.tsx +73 -0
  79. package/src/components/shell/AudienceAddForm.tsx +18 -9
  80. package/src/components/shell/AudienceDetailsPanel.tsx +263 -0
  81. package/src/components/shell/AudienceRow.tsx +55 -13
  82. package/src/components/shell/CredentialList.tsx +285 -0
  83. package/src/components/shell/EditorShell.tsx +94 -4
  84. package/src/components/shell/SiteSettingsViewerAccess.tsx +57 -6
  85. package/src/components/shell/ViewerLoginForm.tsx +35 -15
  86. package/src/lib/audience-overview.ts +110 -0
  87. package/src/lib/audience-secret.ts +99 -0
  88. package/src/lib/events.ts +3 -0
  89. package/src/lib/nav.ts +6 -2
  90. package/src/lib/thumb-views.ts +28 -0
  91. package/src/schemas/audience.ts +52 -0
  92. package/src/schemas/auth.ts +6 -0
  93. package/src/schemas/site-config.ts +1 -1
  94. package/src/types/database.ts +59 -5
  95. package/dist/chunk-ZDQ3TT47.js +0 -30
@@ -4,6 +4,8 @@ import { Input } from "../shared/Input";
4
4
  import { PasswordInput } from "../shared/PasswordInput";
5
5
  import { Button } from "../shared/Button";
6
6
  import { ColorPicker } from "../shared/ColorPicker";
7
+ import { CredentialList } from "./CredentialList";
8
+ import type { ViewerCredential } from "../../schemas/audience";
7
9
 
8
10
  interface Props {
9
11
  audience: Audience;
@@ -11,6 +13,15 @@ interface Props {
11
13
  canMutate: boolean;
12
14
  onSave: (patch: { displayName?: string; color?: string | null; password?: string }) => Promise<void>;
13
15
  onDelete: () => Promise<void>;
16
+ /** Named sign-ins for this audience. Undefined in `.env`/audience mode, where
17
+ * the row keeps its single audience-level password field. */
18
+ credentials?: ViewerCredential[];
19
+ credentialActions?: {
20
+ create: (data: { username: string; label: string | null; password: string }) => Promise<void>;
21
+ setPassword: (username: string, password: string) => Promise<void>;
22
+ setLabel: (username: string, label: string | null) => Promise<void>;
23
+ delete: (username: string) => Promise<void>;
24
+ };
14
25
  }
15
26
 
16
27
  const PLACEHOLDER_COLOR = "#94a3b8";
@@ -21,7 +32,12 @@ export function AudienceRow({
21
32
  canMutate,
22
33
  onSave,
23
34
  onDelete,
35
+ credentials,
36
+ credentialActions,
24
37
  }: Props) {
38
+ // Credential mode: access comes from named sign-ins, so the row's own password
39
+ // field is gone. The two are never shown together.
40
+ const credentialMode = Boolean(credentials && credentialActions);
25
41
  const [isEditing, setIsEditing] = useState(false);
26
42
  const [draftDisplayName, setDraftDisplayName] = useState(audience.displayName);
27
43
  const [draftColor, setDraftColor] = useState(audience.color ?? PLACEHOLDER_COLOR);
@@ -76,7 +92,13 @@ export function AudienceRow({
76
92
  }
77
93
 
78
94
  async function remove() {
79
- if (!confirm(`Remove audience "${audience.displayName}"?`)) return;
95
+ const signIns = audience.credentialCount;
96
+ if (!confirm(
97
+ `Remove audience "${audience.displayName}"?` +
98
+ (signIns > 0
99
+ ? `\n\nIts ${signIns === 1 ? "sign-in" : `${signIns} sign-ins`} will be removed too, locking out anyone using them.`
100
+ : ""),
101
+ )) return;
80
102
  try {
81
103
  await onDelete();
82
104
  } catch (e) {
@@ -95,17 +117,21 @@ export function AudienceRow({
95
117
  value={draftDisplayName}
96
118
  onChange={setDraftDisplayName}
97
119
  />
98
- <PasswordInput
99
- label="New password"
100
- value={draftPassword}
101
- onChange={setDraftPassword}
102
- autoComplete="new-password"
103
- placeholder={audience.hasPassword ? "Leave blank to keep current" : "Required"}
104
- showToggle
105
- />
106
- <span className="block text-[11px] text-base-contrast-light">
107
- Passwords are hashed — the existing one can't be recovered, only replaced.
108
- </span>
120
+ {!credentialMode && (
121
+ <>
122
+ <PasswordInput
123
+ label="New password"
124
+ value={draftPassword}
125
+ onChange={setDraftPassword}
126
+ autoComplete="new-password"
127
+ placeholder={audience.hasPassword ? "Leave blank to keep current" : "Required"}
128
+ showToggle
129
+ />
130
+ <span className="block text-[11px] text-base-contrast-light">
131
+ Passwords are hashed — the existing one can't be recovered, only replaced.
132
+ </span>
133
+ </>
134
+ )}
109
135
  </div>
110
136
  </div>
111
137
  {error && <p className="text-xs text-red-600">{error}</p>}
@@ -148,7 +174,11 @@ export function AudienceRow({
148
174
  </span>
149
175
  )}
150
176
  </span>
151
- {audience.hasPassword ? (
177
+ {credentialMode ? (
178
+ <span className="text-xs text-base-contrast-light">
179
+ {audience.credentialCount === 1 ? "1 sign-in" : `${audience.credentialCount} sign-ins`}
180
+ </span>
181
+ ) : audience.hasPassword ? (
152
182
  <span
153
183
  aria-label="Password is set"
154
184
  title="Passwords are hashed and can't be shown. Edit to set a new one."
@@ -166,6 +196,18 @@ export function AudienceRow({
166
196
  <Button variant="ghost" tone="destructive" onClick={remove}>Remove</Button>
167
197
  )}
168
198
  </div>
199
+
200
+ {credentials && credentialActions && (
201
+ <CredentialList
202
+ audienceName={audience.displayName}
203
+ credentials={credentials}
204
+ canMutate={canMutate}
205
+ onCreate={credentialActions.create}
206
+ onSetPassword={credentialActions.setPassword}
207
+ onSetLabel={credentialActions.setLabel}
208
+ onDelete={credentialActions.delete}
209
+ />
210
+ )}
169
211
  </div>
170
212
  );
171
213
  }
@@ -0,0 +1,285 @@
1
+ import { useState } from "react";
2
+ import { Eye, Copy, Check } from "lucide-react";
3
+ import { Input } from "../shared/Input";
4
+ import { PasswordInput } from "../shared/PasswordInput";
5
+ import { Button } from "../shared/Button";
6
+ import {
7
+ ViewerUsernameSchema,
8
+ VIEWER_PASSWORD_MIN_LENGTH,
9
+ normalizeUsername,
10
+ slugifyAudienceName,
11
+ } from "../../schemas/audience";
12
+ import type { ViewerCredential } from "../../schemas/audience";
13
+
14
+ interface Props {
15
+ audienceName: string;
16
+ credentials: ViewerCredential[];
17
+ canMutate: boolean;
18
+ onCreate: (data: { username: string; label: string | null; password: string }) => Promise<void>;
19
+ onSetPassword: (username: string, password: string) => Promise<void>;
20
+ onSetLabel: (username: string, label: string | null) => Promise<void>;
21
+ onDelete: (username: string) => Promise<void>;
22
+ }
23
+
24
+ function CopyableSecret({ value }: { value: string }) {
25
+ const [copied, setCopied] = useState(false);
26
+ return (
27
+ <span className="flex items-center gap-2">
28
+ <code className="rounded bg-base-accent px-2 py-1 font-mono text-xs text-base-contrast select-all">{value}</code>
29
+ <Button
30
+ variant="ghost"
31
+ aria-label="Copy password"
32
+ onClick={async () => {
33
+ try {
34
+ await navigator.clipboard.writeText(value);
35
+ setCopied(true);
36
+ setTimeout(() => setCopied(false), 2000);
37
+ } catch {
38
+ // Clipboard blocked — the value is on screen and selectable.
39
+ }
40
+ }}
41
+ >
42
+ {copied ? <Check className="h-3.5 w-3.5" aria-hidden /> : <Copy className="h-3.5 w-3.5" aria-hidden />}
43
+ </Button>
44
+ </span>
45
+ );
46
+ }
47
+
48
+ function CredentialRow({
49
+ credential, canMutate, onSetPassword, onSetLabel, onDelete,
50
+ }: {
51
+ credential: ViewerCredential;
52
+ canMutate: boolean;
53
+ onSetPassword: (username: string, password: string) => Promise<void>;
54
+ onSetLabel: (username: string, label: string | null) => Promise<void>;
55
+ onDelete: (username: string) => Promise<void>;
56
+ }) {
57
+ const [editing, setEditing] = useState(false);
58
+ const [label, setLabel] = useState(credential.label ?? "");
59
+ const [password, setPassword] = useState("");
60
+ const [revealed, setRevealed] = useState<string | null>(null);
61
+ const [busy, setBusy] = useState(false);
62
+ const [error, setError] = useState<string | null>(null);
63
+
64
+ async function reveal() {
65
+ setError(null);
66
+ setBusy(true);
67
+ try {
68
+ const res = await fetch("/api/auth/viewer-password", {
69
+ method: "POST",
70
+ headers: { "Content-Type": "application/json" },
71
+ body: JSON.stringify({ username: credential.username }),
72
+ });
73
+ const body = await res.json().catch(() => ({}));
74
+ if (body.status === "revealed") setRevealed(body.password);
75
+ else if (body.status === "unavailable") {
76
+ setError("Hidden. Set a new password to view here.");
77
+ } else setError(body.error ?? "Couldn't load the password.");
78
+ } catch {
79
+ setError("Network error");
80
+ } finally {
81
+ setBusy(false);
82
+ }
83
+ }
84
+
85
+ async function save() {
86
+ setError(null);
87
+ if (password.length > 0 && password.length < VIEWER_PASSWORD_MIN_LENGTH) {
88
+ setError(`Password must be at least ${VIEWER_PASSWORD_MIN_LENGTH} characters`);
89
+ return;
90
+ }
91
+ setBusy(true);
92
+ try {
93
+ const nextLabel = label.trim().length > 0 ? label.trim() : null;
94
+ if (nextLabel !== (credential.label ?? null)) await onSetLabel(credential.username, nextLabel);
95
+ if (password.length > 0) {
96
+ await onSetPassword(credential.username, password);
97
+ // Show what was just set, so it can be shared without a second lookup.
98
+ setRevealed(password);
99
+ }
100
+ setPassword("");
101
+ setEditing(false);
102
+ } catch (e) {
103
+ setError(e instanceof Error ? e.message : "Save failed");
104
+ } finally {
105
+ setBusy(false);
106
+ }
107
+ }
108
+
109
+ async function remove() {
110
+ if (!confirm(
111
+ `Remove the sign-in "${credential.username}"?\n\n` +
112
+ "Anyone using it will be locked out — though a browser already signed in " +
113
+ "may keep access for up to 24 hours, until its session expires.",
114
+ )) return;
115
+ setError(null);
116
+ try {
117
+ await onDelete(credential.username);
118
+ } catch (e) {
119
+ setError(e instanceof Error ? e.message : "Remove failed");
120
+ }
121
+ }
122
+
123
+ return (
124
+ <li className="rounded border border-base-200 px-3 py-2">
125
+ <div className="flex flex-wrap items-center gap-2">
126
+ <span className="min-w-0 flex-1">
127
+ <span className="block truncate font-mono text-sm text-base-contrast">{credential.username}</span>
128
+ {credential.label && !editing && (
129
+ <span className="block truncate text-[11px] text-base-contrast-light">{credential.label}</span>
130
+ )}
131
+ </span>
132
+
133
+ {revealed ? (
134
+ <CopyableSecret value={revealed} />
135
+ ) : (
136
+ <Button variant="ghost" onClick={reveal} isLoading={busy && !editing} loadingLabel="...">
137
+ <span className="inline-flex items-center gap-1.5">
138
+ <Eye className="h-3.5 w-3.5" aria-hidden />
139
+ Show
140
+ </span>
141
+ </Button>
142
+ )}
143
+ {revealed && <Button variant="ghost" onClick={() => setRevealed(null)}>Hide</Button>}
144
+
145
+ {canMutate && !editing && <Button variant="ghost" onClick={() => setEditing(true)}>Edit</Button>}
146
+ {canMutate && !editing && (
147
+ <Button variant="ghost" tone="destructive" onClick={remove}>Remove</Button>
148
+ )}
149
+ </div>
150
+
151
+ {editing && (
152
+ <div className="mt-2 space-y-2">
153
+ <Input label="Label" value={label} onChange={setLabel} placeholder="Login Name" />
154
+ <PasswordInput
155
+ label="New password"
156
+ value={password}
157
+ onChange={setPassword}
158
+ autoComplete="new-password"
159
+ placeholder="Leave blank to keep the current one"
160
+ showToggle
161
+ />
162
+ <div className="flex justify-end gap-2">
163
+ <Button variant="secondary" onClick={() => { setEditing(false); setPassword(""); setLabel(credential.label ?? ""); setError(null); }} disabled={busy}>
164
+ Cancel
165
+ </Button>
166
+ <Button variant="primary" onClick={save} isLoading={busy} loadingLabel="Saving...">Save</Button>
167
+ </div>
168
+ </div>
169
+ )}
170
+
171
+ {error && <p className="mt-1 text-xs text-red-600">{error}</p>}
172
+ </li>
173
+ );
174
+ }
175
+
176
+ function AddForm({ audienceName, onCreate, onCancel }: {
177
+ audienceName: string;
178
+ onCreate: Props["onCreate"];
179
+ onCancel: () => void;
180
+ }) {
181
+ const [label, setLabel] = useState("");
182
+ const [username, setUsername] = useState("");
183
+ // The username follows the label until the editor takes it over, so the
184
+ // common case is a single field and the slug stays predictable. Once they
185
+ // type in the username it stops tracking — otherwise their edit would be
186
+ // silently overwritten by the next keystroke in the label.
187
+ const [usernameOverridden, setUsernameOverridden] = useState(false);
188
+ const [password, setPassword] = useState("");
189
+ const [error, setError] = useState<string | null>(null);
190
+ const [busy, setBusy] = useState(false);
191
+
192
+ const derivedUsername = usernameOverridden ? username : slugifyAudienceName(label);
193
+
194
+ function editUsername(value: string) {
195
+ setUsernameOverridden(true);
196
+ setUsername(value);
197
+ }
198
+
199
+ async function submit() {
200
+ setError(null);
201
+ const normalized = normalizeUsername(derivedUsername);
202
+ if (normalized.length === 0) {
203
+ setError("Add a label — the username is generated from it");
204
+ return;
205
+ }
206
+ const parsed = ViewerUsernameSchema.safeParse(normalized);
207
+ if (!parsed.success) {
208
+ setError("Username: lowercase letters, numbers, dots, dashes and underscores only");
209
+ return;
210
+ }
211
+ if (password.length < VIEWER_PASSWORD_MIN_LENGTH) {
212
+ setError(`Password must be at least ${VIEWER_PASSWORD_MIN_LENGTH} characters`);
213
+ return;
214
+ }
215
+ setBusy(true);
216
+ try {
217
+ await onCreate({ username: normalized, label: label.trim() || null, password });
218
+ onCancel();
219
+ } catch (e) {
220
+ setError(e instanceof Error ? e.message : "Add failed");
221
+ } finally {
222
+ setBusy(false);
223
+ }
224
+ }
225
+
226
+ return (
227
+ <li className="space-y-2 rounded border border-base-200 px-3 py-3">
228
+ <Input label="Label" value={label} onChange={setLabel} placeholder="Login Name" />
229
+ <Input
230
+ label="Username"
231
+ value={derivedUsername}
232
+ onChange={editUsername}
233
+ autoCapitalize="none"
234
+ spellCheck={false}
235
+ />
236
+ <p className="text-[11px] text-base-contrast-light">
237
+ Generated from the label — change it if you want something different. This is what they type to sign in, and it can't be renamed later.
238
+ </p>
239
+ <PasswordInput label="Password" value={password} onChange={setPassword} autoComplete="new-password" showToggle />
240
+ <p className="text-[11px] text-base-contrast-light">
241
+ Signs in to <span className="font-medium">{audienceName}</span> and sees exactly what that audience sees.
242
+ You can look this password up again later.
243
+ </p>
244
+ {error && <p className="text-xs text-red-600">{error}</p>}
245
+ <div className="flex justify-end gap-2">
246
+ <Button variant="secondary" onClick={onCancel} disabled={busy}>Cancel</Button>
247
+ <Button variant="primary" onClick={submit} isLoading={busy} loadingLabel="Adding...">Add</Button>
248
+ </div>
249
+ </li>
250
+ );
251
+ }
252
+
253
+ /** The named sign-ins for one audience, inside its Site Settings row. */
254
+ export function CredentialList({
255
+ audienceName, credentials, canMutate, onCreate, onSetPassword, onSetLabel, onDelete,
256
+ }: Props) {
257
+ const [adding, setAdding] = useState(false);
258
+
259
+ return (
260
+ <div className="mt-2 space-y-2 border-t border-base-200 pt-2">
261
+ <p className="text-[11px] font-medium uppercase tracking-wide text-base-contrast-light">Sign-ins</p>
262
+ <ul className="space-y-2">
263
+ {credentials.map((c) => (
264
+ <CredentialRow
265
+ key={c.username}
266
+ credential={c}
267
+ canMutate={canMutate}
268
+ onSetPassword={onSetPassword}
269
+ onSetLabel={onSetLabel}
270
+ onDelete={onDelete}
271
+ />
272
+ ))}
273
+ {credentials.length === 0 && !adding && (
274
+ <li className="text-xs text-base-contrast-light">
275
+ No sign-ins yet — nobody can enter as this audience.
276
+ </li>
277
+ )}
278
+ {adding && <AddForm audienceName={audienceName} onCreate={onCreate} onCancel={() => setAdding(false)} />}
279
+ </ul>
280
+ {canMutate && !adding && (
281
+ <Button variant="secondary" onClick={() => setAdding(true)}>Add sign-in</Button>
282
+ )}
283
+ </div>
284
+ );
285
+ }
@@ -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)}>