@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.
- 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-EWNDBKYK.js → chunk-LUAI5DWR.js} +1 -1
- package/dist/chunk-NLQ5F3RQ.js +43 -0
- package/dist/{chunk-X4PRBGB3.js → chunk-QLBHERK6.js} +9 -9
- package/dist/{chunk-3COSXTOY.js → chunk-XBYJQ5RS.js} +1 -1
- package/dist/{chunk-CXQWLG6X.js → chunk-Z7VXESB6.js} +18 -18
- 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/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/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/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/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/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 +94 -4
- 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/events.ts +3 -0
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
{
|
|
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,
|
|
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
|
|
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)}>
|