@drawnagency/primitives 0.1.105 → 0.1.107
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/dist/auth/cookies.d.ts +21 -1
- package/dist/auth/cookies.d.ts.map +1 -1
- package/dist/auth/index.d.ts +2 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +3 -1
- package/dist/auth/types.d.ts +28 -0
- package/dist/auth/types.d.ts.map +1 -1
- package/dist/{chunk-PTOLUMGV.js → chunk-4RNF2DRG.js} +1 -0
- package/dist/{chunk-MUTHF2GT.js → chunk-5IZ2KTMU.js} +2 -2
- package/dist/{chunk-LZKVV4K2.js → chunk-6P4CJCXB.js} +7 -1
- package/dist/{chunk-CZLYECHC.js → chunk-AB3XVXI3.js} +4 -4
- package/dist/chunk-B7VBSE5X.js +12 -0
- package/dist/{chunk-BGHWUJFV.js → chunk-BOZUEKWL.js} +107 -171
- package/dist/{chunk-TGNS7D4F.js → chunk-CPTFFLK2.js} +1 -1
- package/dist/{chunk-26ALJO4K.js → chunk-D6TLUVOP.js} +1 -1
- package/dist/{chunk-EM5DFHDZ.js → chunk-GEENI3OC.js} +13 -4
- package/dist/chunk-GGXZZ77Z.js +70 -0
- package/dist/{chunk-X4PRBGB3.js → chunk-IPKZ5FU6.js} +83 -33
- package/dist/{chunk-CXQWLG6X.js → chunk-JIQBLG6Y.js} +23 -20
- package/dist/{chunk-EWNDBKYK.js → chunk-LUAI5DWR.js} +1 -1
- package/dist/chunk-NLQ5F3RQ.js +43 -0
- package/dist/{chunk-3COSXTOY.js → chunk-XBYJQ5RS.js} +1 -1
- package/dist/{chunk-WA6QPDJI.js → chunk-ZHHJVKU7.js} +6 -6
- package/dist/collab/rebase.js +2 -1
- package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -1
- package/dist/components/editor/index.js +8 -7
- package/dist/components/primitives/index.js +7 -6
- package/dist/components/sections/Container/Container.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +8 -7
- package/dist/components/sections/register-schemas.js +13 -12
- package/dist/components/sections/register.js +13 -12
- package/dist/components/shared/Navigation.d.ts +16 -1
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/SectionSummaryRow.d.ts +24 -0
- package/dist/components/shared/SectionSummaryRow.d.ts.map +1 -0
- package/dist/components/shell/AudienceAddForm.d.ts +4 -1
- package/dist/components/shell/AudienceAddForm.d.ts.map +1 -1
- package/dist/components/shell/AudienceDetailsPanel.d.ts +28 -0
- package/dist/components/shell/AudienceDetailsPanel.d.ts.map +1 -0
- package/dist/components/shell/AudienceRow.d.ts +15 -1
- package/dist/components/shell/AudienceRow.d.ts.map +1 -1
- package/dist/components/shell/CredentialList.d.ts +18 -0
- package/dist/components/shell/CredentialList.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/components/shell/SectionTypePicker.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsViewerAccess.d.ts.map +1 -1
- package/dist/components/shell/ViewerLoginForm.d.ts +5 -1
- package/dist/components/shell/ViewerLoginForm.d.ts.map +1 -1
- package/dist/index.js +47 -29
- package/dist/lib/audience-overview.d.ts +53 -0
- package/dist/lib/audience-overview.d.ts.map +1 -0
- package/dist/lib/audience-secret.d.ts +11 -0
- package/dist/lib/audience-secret.d.ts.map +1 -0
- package/dist/lib/audience-secret.js +70 -0
- package/dist/lib/container-ops.d.ts.map +1 -1
- package/dist/lib/events.d.ts +3 -0
- package/dist/lib/events.d.ts.map +1 -1
- package/dist/lib/index-ops.js +3 -2
- package/dist/lib/index.js +13 -12
- package/dist/lib/insert-menu.d.ts +7 -3
- package/dist/lib/insert-menu.d.ts.map +1 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/thumb-views.d.ts +17 -0
- package/dist/lib/thumb-views.d.ts.map +1 -0
- package/dist/media/index.js +4 -3
- package/dist/schemas/audience.d.ts +29 -0
- package/dist/schemas/audience.d.ts.map +1 -1
- package/dist/schemas/audience.js +24 -0
- package/dist/schemas/auth.d.ts +1 -0
- package/dist/schemas/auth.d.ts.map +1 -1
- package/dist/schemas/auth.js +1 -1
- package/dist/schemas/index.js +28 -12
- package/dist/schemas/site-config.d.ts +1 -1
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/dist/types/database.d.ts +59 -5
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +9 -1
- package/src/auth/cookies.ts +37 -4
- package/src/auth/index.ts +2 -1
- package/src/auth/types.ts +32 -0
- package/src/components/editor/SectionOrderingModal.tsx +8 -73
- package/src/components/sections/Container/Container.tsx +12 -2
- package/src/components/sections/IconList/IconList.tsx +75 -22
- package/src/components/shared/Navigation.tsx +113 -7
- package/src/components/shared/SectionSummaryRow.tsx +73 -0
- package/src/components/shell/AudienceAddForm.tsx +18 -9
- package/src/components/shell/AudienceDetailsPanel.tsx +263 -0
- package/src/components/shell/AudienceRow.tsx +55 -13
- package/src/components/shell/CredentialList.tsx +285 -0
- package/src/components/shell/EditorShell.tsx +98 -5
- package/src/components/shell/MediaLibraryModal.tsx +42 -5
- package/src/components/shell/SectionTypePicker.tsx +28 -2
- package/src/components/shell/SiteSettingsViewerAccess.tsx +57 -6
- package/src/components/shell/ViewerLoginForm.tsx +35 -15
- package/src/lib/audience-overview.ts +110 -0
- package/src/lib/audience-secret.ts +99 -0
- package/src/lib/container-ops.ts +6 -3
- package/src/lib/events.ts +3 -0
- package/src/lib/insert-menu.ts +38 -4
- package/src/lib/nav.ts +6 -2
- package/src/lib/thumb-views.ts +28 -0
- package/src/schemas/audience.ts +52 -0
- package/src/schemas/auth.ts +6 -0
- package/src/schemas/site-config.ts +1 -1
- package/src/types/database.ts +59 -5
- package/dist/chunk-ZDQ3TT47.js +0 -30
|
@@ -13,10 +13,19 @@ interface SectionTypePickerProps {
|
|
|
13
13
|
containerRef?: RefObject<HTMLElement | null>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
const BUTTON_CLASS =
|
|
17
|
+
"cursor-pointer flex items-center gap-3 rounded-lg border border-base-200 bg-base px-4 py-3 text-left text-sm text-base-contrast-light transition-colors hover:border-base-300 hover:bg-base-accent hover:text-base-contrast";
|
|
18
|
+
|
|
16
19
|
export function SectionTypePicker({ types, onSelect, onClose, containerRef }: SectionTypePickerProps) {
|
|
17
20
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
18
21
|
const dismissRef = containerRef ?? panelRef;
|
|
19
22
|
|
|
23
|
+
// Container is a layout choice rather than a content block, so it gets its own
|
|
24
|
+
// full-width row above a divider. It's absent from the container child picker
|
|
25
|
+
// (one level of nesting), in which case there's no lead row and no divider.
|
|
26
|
+
const lead = types.find((t) => t.type === "container");
|
|
27
|
+
const rest = types.filter((t) => t !== lead);
|
|
28
|
+
|
|
20
29
|
useEffect(() => {
|
|
21
30
|
function handleMouseDown(e: MouseEvent) {
|
|
22
31
|
if (dismissRef.current && !dismissRef.current.contains(e.target as Node)) {
|
|
@@ -32,10 +41,27 @@ export function SectionTypePicker({ types, onSelect, onClose, containerRef }: Se
|
|
|
32
41
|
ref={panelRef}
|
|
33
42
|
className="z-50 mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3"
|
|
34
43
|
>
|
|
35
|
-
{
|
|
44
|
+
{lead && (
|
|
45
|
+
<>
|
|
46
|
+
<button
|
|
47
|
+
key={lead.type}
|
|
48
|
+
className={`${BUTTON_CLASS} col-span-full`}
|
|
49
|
+
onClick={() => onSelect(lead.type)}
|
|
50
|
+
>
|
|
51
|
+
{lead.icon && (
|
|
52
|
+
<span className="flex h-5 w-5 shrink-0 items-center justify-center text-base-contrast-light/60">
|
|
53
|
+
{lead.icon}
|
|
54
|
+
</span>
|
|
55
|
+
)}
|
|
56
|
+
<span>{lead.label}</span>
|
|
57
|
+
</button>
|
|
58
|
+
<hr className="col-span-full border-base-200" />
|
|
59
|
+
</>
|
|
60
|
+
)}
|
|
61
|
+
{rest.map(({ type, label, icon }) => (
|
|
36
62
|
<button
|
|
37
63
|
key={type}
|
|
38
|
-
className=
|
|
64
|
+
className={BUTTON_CLASS}
|
|
39
65
|
onClick={() => onSelect(type)}
|
|
40
66
|
>
|
|
41
67
|
{icon && (
|
|
@@ -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
|
|
25
|
-
|
|
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
|
-
? "
|
|
129
|
-
:
|
|
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
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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/container-ops.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { Section } from "../schemas/sections";
|
|
3
3
|
import { getAllSections } from "./registry";
|
|
4
|
+
import { sortInsertMenuOptions } from "./insert-menu";
|
|
4
5
|
|
|
5
6
|
interface ContainerContent {
|
|
6
7
|
columns: number;
|
|
@@ -40,9 +41,11 @@ export function reorderChild(container: Section, from: number, to: number): Sect
|
|
|
40
41
|
* single, testable source of truth (the container toolbar's "+" picker uses it).
|
|
41
42
|
*/
|
|
42
43
|
export function childInsertableTypes(): { type: string; label: string; icon: ReactNode }[] {
|
|
43
|
-
return
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
return sortInsertMenuOptions(
|
|
45
|
+
getAllSections()
|
|
46
|
+
.filter((d) => d.type !== "container" && d.type !== "spacer")
|
|
47
|
+
.map((d) => ({ type: d.type, label: d.label, icon: d.icon })),
|
|
48
|
+
);
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export function insertChildAt(container: Section, child: Section, index: number): Section {
|
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/insert-menu.ts
CHANGED
|
@@ -12,9 +12,43 @@ export interface InsertMenuOption {
|
|
|
12
12
|
icon?: ReactNode;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
*
|
|
15
|
+
/** Authored display order for the Add-section menu, most-reached-for first.
|
|
16
|
+
* `container` leads because the picker gives it its own full-width row above a
|
|
17
|
+
* divider. Anything absent — client-repo custom sections and their presets —
|
|
18
|
+
* sorts alphabetically after these. */
|
|
19
|
+
const INSERT_MENU_ORDER = [
|
|
20
|
+
"container",
|
|
21
|
+
"link_heading",
|
|
22
|
+
"sub_heading",
|
|
23
|
+
"sub_sub_heading",
|
|
24
|
+
"prose",
|
|
25
|
+
"media",
|
|
26
|
+
"video",
|
|
27
|
+
"button",
|
|
28
|
+
"colors",
|
|
29
|
+
"document",
|
|
30
|
+
"icon_list",
|
|
31
|
+
`${PRESET_PREFIX}do_dont_list`,
|
|
32
|
+
"dodont_media",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Sort insert-menu entries into `INSERT_MENU_ORDER`, with unlisted entries
|
|
36
|
+
* (custom sections) alphabetical by label at the end. Pure; returns a copy. */
|
|
37
|
+
export function sortInsertMenuOptions<T extends InsertMenuOption>(options: T[]): T[] {
|
|
38
|
+
return [...options].sort((a, b) => {
|
|
39
|
+
const ai = INSERT_MENU_ORDER.indexOf(a.type);
|
|
40
|
+
const bi = INSERT_MENU_ORDER.indexOf(b.type);
|
|
41
|
+
if (ai !== -1 && bi !== -1) return ai - bi;
|
|
42
|
+
if (ai !== -1) return -1;
|
|
43
|
+
if (bi !== -1) return 1;
|
|
44
|
+
return a.label.localeCompare(b.label);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Every non-spacer section type, plus presets contributed by registered
|
|
49
|
+
* sections, in `INSERT_MENU_ORDER`. Presets appear only when their owning
|
|
50
|
+
* section is registered, so a core-only build (no brand-guide sections) shows
|
|
51
|
+
* no brand-guide presets. */
|
|
18
52
|
export function buildInsertMenuOptions(sections: SectionDefinition[]): InsertMenuOption[] {
|
|
19
53
|
const typeOptions = sections
|
|
20
54
|
.filter((def) => def.type !== "spacer")
|
|
@@ -22,7 +56,7 @@ export function buildInsertMenuOptions(sections: SectionDefinition[]): InsertMen
|
|
|
22
56
|
const presetOptions = sections
|
|
23
57
|
.flatMap((def) => def.presets ?? [])
|
|
24
58
|
.map((p) => ({ type: `${PRESET_PREFIX}${p.id}`, label: p.label, icon: p.icon }));
|
|
25
|
-
return [...typeOptions, ...presetOptions];
|
|
59
|
+
return sortInsertMenuOptions([...typeOptions, ...presetOptions]);
|
|
26
60
|
}
|
|
27
61
|
|
|
28
62
|
/** Resolve an Add-menu selection to ready-to-insert content. `newId` mints ids for
|
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,
|
|
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
|
-
|
|
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
|
+
}
|