@drawnagency/primitives 0.1.104 → 0.1.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/chat/ChatWidget.d.ts.map +1 -1
- package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -1
- package/dist/components/docfile/DocFilePane.d.ts +4 -3
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -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/chat/ChatPanel.tsx +4 -2
- package/src/components/chat/ChatWidget.tsx +4 -1
- package/src/components/docfile/DocFileOverlay.tsx +28 -2
- package/src/components/docfile/DocFilePane.tsx +4 -3
- 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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ChevronRight, Copy, Check, Eye } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { Button } from "../shared/Button";
|
|
5
|
+
import { SectionSummaryRow } from "../shared/SectionSummaryRow";
|
|
6
|
+
import type { AudienceOverview, OverviewPage } from "../../lib/audience-overview";
|
|
7
|
+
|
|
8
|
+
/** One audience plus its resolved availability. Built server-side. */
|
|
9
|
+
export interface AudienceDetail {
|
|
10
|
+
name: string;
|
|
11
|
+
displayName: string;
|
|
12
|
+
color: string | null;
|
|
13
|
+
isDefault: boolean;
|
|
14
|
+
hasPassword: boolean;
|
|
15
|
+
/** Named sign-ins that grant this audience. Empty in `.env`/audience mode. */
|
|
16
|
+
credentials: Array<{ username: string; label: string | null }>;
|
|
17
|
+
overview: AudienceOverview;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Props {
|
|
21
|
+
audiences: AudienceDetail[];
|
|
22
|
+
/** False when the auth provider cannot store a reversible password at all. */
|
|
23
|
+
revealSupported: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const PLACEHOLDER_COLOR = "#94a3b8";
|
|
27
|
+
|
|
28
|
+
type RevealState =
|
|
29
|
+
| { kind: "hidden" }
|
|
30
|
+
| { kind: "loading" }
|
|
31
|
+
| { kind: "shown"; password: string }
|
|
32
|
+
| { kind: "message"; text: string };
|
|
33
|
+
|
|
34
|
+
async function fetchPassword(username: string): Promise<RevealState> {
|
|
35
|
+
let res: Response;
|
|
36
|
+
try {
|
|
37
|
+
res = await fetch("/api/auth/viewer-password", {
|
|
38
|
+
method: "POST",
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
body: JSON.stringify({ username }),
|
|
41
|
+
});
|
|
42
|
+
} catch {
|
|
43
|
+
return { kind: "message", text: "Network error — try again." };
|
|
44
|
+
}
|
|
45
|
+
if (res.status === 429) return { kind: "message", text: "Too many attempts. Try again in a few minutes." };
|
|
46
|
+
if (res.status === 403) return { kind: "message", text: "You don't have permission to view this." };
|
|
47
|
+
if (!res.ok) return { kind: "message", text: "Couldn't load the password." };
|
|
48
|
+
|
|
49
|
+
const body = await res.json().catch(() => ({}));
|
|
50
|
+
if (body.status === "revealed") return { kind: "shown", password: body.password };
|
|
51
|
+
// "unavailable": the honest case. Created before passwords could be shown, or
|
|
52
|
+
// changed by something that didn't store a readable copy.
|
|
53
|
+
return { kind: "message", text: "Hidden. Set a new password to view here." };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function PasswordCell({ username, revealSupported }: { username: string; revealSupported: boolean }) {
|
|
57
|
+
const [state, setState] = useState<RevealState>({ kind: "hidden" });
|
|
58
|
+
const [copied, setCopied] = useState(false);
|
|
59
|
+
|
|
60
|
+
if (!revealSupported) {
|
|
61
|
+
return <span className="text-xs tracking-widest text-base-contrast-light">••••</span>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function reveal() {
|
|
65
|
+
setState({ kind: "loading" });
|
|
66
|
+
setState(await fetchPassword(username));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function copy(password: string) {
|
|
70
|
+
try {
|
|
71
|
+
await navigator.clipboard.writeText(password);
|
|
72
|
+
setCopied(true);
|
|
73
|
+
setTimeout(() => setCopied(false), 2000);
|
|
74
|
+
} catch {
|
|
75
|
+
// Clipboard blocked (insecure context, denied permission) — the password
|
|
76
|
+
// is on screen and selectable, so this needs no error surface.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (state.kind === "shown") {
|
|
81
|
+
return (
|
|
82
|
+
<span className="flex items-center gap-2">
|
|
83
|
+
<code className="rounded bg-base-accent px-2 py-1 font-mono text-xs text-base-contrast select-all">
|
|
84
|
+
{state.password}
|
|
85
|
+
</code>
|
|
86
|
+
<Button variant="ghost" onClick={() => copy(state.password)} aria-label="Copy password">
|
|
87
|
+
{copied
|
|
88
|
+
? <Check className="h-3.5 w-3.5" aria-hidden />
|
|
89
|
+
: <Copy className="h-3.5 w-3.5" aria-hidden />}
|
|
90
|
+
</Button>
|
|
91
|
+
<Button variant="ghost" onClick={() => setState({ kind: "hidden" })}>Hide</Button>
|
|
92
|
+
</span>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (state.kind === "message") {
|
|
97
|
+
return (
|
|
98
|
+
<span className="flex items-center gap-2">
|
|
99
|
+
<span className="max-w-xs text-right text-xs text-base-contrast-light">{state.text}</span>
|
|
100
|
+
<Button variant="ghost" onClick={() => setState({ kind: "hidden" })}>Dismiss</Button>
|
|
101
|
+
</span>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<Button variant="ghost" onClick={reveal} isLoading={state.kind === "loading"} loadingLabel="Loading...">
|
|
107
|
+
<span className="inline-flex items-center gap-1.5">
|
|
108
|
+
<Eye className="h-3.5 w-3.5" aria-hidden />
|
|
109
|
+
Show password
|
|
110
|
+
</span>
|
|
111
|
+
</Button>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function PageRow({ page }: { page: OverviewPage }) {
|
|
116
|
+
const [open, setOpen] = useState(false);
|
|
117
|
+
const availableCount = page.sections.filter((s) => s.available).length;
|
|
118
|
+
const blocked = page.availability === "none";
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<li className="rounded border border-base-200">
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => setOpen((p) => !p)}
|
|
125
|
+
aria-expanded={open}
|
|
126
|
+
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left"
|
|
127
|
+
>
|
|
128
|
+
<ChevronRight className={cn("h-3.5 w-3.5 shrink-0 text-base-contrast-light transition-transform", open && "rotate-90")} aria-hidden />
|
|
129
|
+
<span className={cn("flex-1 truncate text-sm text-base-contrast", blocked && "opacity-50")}>
|
|
130
|
+
{page.title}
|
|
131
|
+
{page.hiddenFromNav && (
|
|
132
|
+
<span className="ml-2 rounded bg-base-accent px-1.5 py-0.5 text-[11px] text-base-contrast-light">
|
|
133
|
+
Not in menu
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
</span>
|
|
137
|
+
<span className="shrink-0 text-xs text-base-contrast-light">
|
|
138
|
+
{blocked
|
|
139
|
+
? "Whole page hidden"
|
|
140
|
+
: `${availableCount} of ${page.sections.length} sections`}
|
|
141
|
+
</span>
|
|
142
|
+
</button>
|
|
143
|
+
|
|
144
|
+
{open && (
|
|
145
|
+
<div className="flex flex-col gap-2 border-t border-base-200 px-3 py-2">
|
|
146
|
+
{page.sections.length === 0 && (
|
|
147
|
+
<p className="text-xs text-base-contrast-light">This page has no sections yet.</p>
|
|
148
|
+
)}
|
|
149
|
+
{page.sections.map((s) => (
|
|
150
|
+
<SectionSummaryRow
|
|
151
|
+
key={s.id}
|
|
152
|
+
label={s.label}
|
|
153
|
+
typeLabel={s.typeLabel}
|
|
154
|
+
thumbnails={s.thumbnails}
|
|
155
|
+
muted={!s.available}
|
|
156
|
+
trailing={
|
|
157
|
+
s.available ? null : (
|
|
158
|
+
<span className="shrink-0 rounded bg-base-accent px-1.5 py-0.5 text-[11px] text-base-contrast-light">
|
|
159
|
+
{s.draft ? "Draft" : "Hidden"}
|
|
160
|
+
</span>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
))}
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
</li>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function AudienceBlock({ audience, revealSupported }: { audience: AudienceDetail; revealSupported: boolean }) {
|
|
172
|
+
const [open, setOpen] = useState(false);
|
|
173
|
+
const { overview } = audience;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<section className="rounded-lg border border-base-200">
|
|
177
|
+
<div className="flex flex-wrap items-center gap-3 px-4 py-3">
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={() => setOpen((p) => !p)}
|
|
181
|
+
aria-expanded={open}
|
|
182
|
+
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 text-left"
|
|
183
|
+
>
|
|
184
|
+
<ChevronRight className={cn("h-4 w-4 shrink-0 text-base-contrast-light transition-transform", open && "rotate-90")} aria-hidden />
|
|
185
|
+
<span
|
|
186
|
+
aria-hidden
|
|
187
|
+
className="inline-block h-4 w-4 shrink-0 rounded-full border border-base-200"
|
|
188
|
+
style={{ backgroundColor: audience.color ?? PLACEHOLDER_COLOR }}
|
|
189
|
+
/>
|
|
190
|
+
<span className="min-w-0">
|
|
191
|
+
<span className="flex items-center gap-2 text-sm font-medium text-base-contrast">
|
|
192
|
+
<span className="truncate">{audience.displayName}</span>
|
|
193
|
+
{audience.isDefault && (
|
|
194
|
+
<span className="rounded bg-base-accent px-1.5 py-0.5 text-xs text-base-contrast-light">Default</span>
|
|
195
|
+
)}
|
|
196
|
+
</span>
|
|
197
|
+
<span className="block text-xs text-base-contrast-light">
|
|
198
|
+
{overview.pagesAvailable} of {overview.pagesTotal} pages ·{" "}
|
|
199
|
+
{overview.sectionsAvailable} of {overview.sectionsTotal} sections
|
|
200
|
+
</span>
|
|
201
|
+
</span>
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div className="border-t border-base-200 px-4 py-3">
|
|
206
|
+
{audience.credentials.length === 0 ? (
|
|
207
|
+
<p className="text-xs text-base-contrast-light">
|
|
208
|
+
{audience.hasPassword
|
|
209
|
+
// A legacy audience-level password, from a site that has not been
|
|
210
|
+
// migrated to named sign-ins.
|
|
211
|
+
? "One shared password, set outside sign-ins — not shown here."
|
|
212
|
+
: "No sign-ins yet — add one in Site settings → Viewer access."}
|
|
213
|
+
</p>
|
|
214
|
+
) : (
|
|
215
|
+
<ul className="flex flex-col gap-2">
|
|
216
|
+
{audience.credentials.map((c) => (
|
|
217
|
+
<li key={c.username} className="flex flex-wrap items-center gap-3">
|
|
218
|
+
<span className="min-w-0 flex-1">
|
|
219
|
+
<span className="block truncate font-mono text-sm text-base-contrast">{c.username}</span>
|
|
220
|
+
{c.label && (
|
|
221
|
+
<span className="block truncate text-[11px] text-base-contrast-light">{c.label}</span>
|
|
222
|
+
)}
|
|
223
|
+
</span>
|
|
224
|
+
<PasswordCell username={c.username} revealSupported={revealSupported} />
|
|
225
|
+
</li>
|
|
226
|
+
))}
|
|
227
|
+
</ul>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
|
|
231
|
+
{open && (
|
|
232
|
+
<ul className="flex flex-col gap-2 border-t border-base-200 px-4 py-3">
|
|
233
|
+
{overview.pages.map((page) => <PageRow key={page.id} page={page} />)}
|
|
234
|
+
</ul>
|
|
235
|
+
)}
|
|
236
|
+
</section>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The Audience Details page body: who can see what, and what password to share.
|
|
242
|
+
* Reachable by editors and the site's default audience (the client), so the
|
|
243
|
+
* client can hand a new partner access without going through us.
|
|
244
|
+
*/
|
|
245
|
+
export function AudienceDetailsPanel({ audiences, revealSupported }: Props) {
|
|
246
|
+
if (audiences.length === 0) {
|
|
247
|
+
return (
|
|
248
|
+
<p className="text-sm text-base-contrast-light">
|
|
249
|
+
No audiences yet. Add one in Site settings → Viewer access.
|
|
250
|
+
</p>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<div className="flex flex-col gap-3">
|
|
256
|
+
{audiences.map((a) => (
|
|
257
|
+
<AudienceBlock key={a.name} audience={a} revealSupported={revealSupported} />
|
|
258
|
+
))}
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export default AudienceDetailsPanel;
|
|
@@ -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
|
+
}
|