@drawnagency/primitives 0.1.104 → 0.1.106

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