@hadoku/task-ui-components 4.20.4 → 4.20.6
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/app-header.css +35 -0
- package/dist/components/ConnectedSettings.d.ts.map +1 -1
- package/dist/components/ConnectedSettings.js +132 -64
- package/dist/components/ConnectedSettings.js.map +1 -1
- package/dist/lib/settingsClient.d.ts +10 -3
- package/dist/lib/settingsClient.d.ts.map +1 -1
- package/dist/lib/settingsClient.js +11 -4
- package/dist/lib/settingsClient.js.map +1 -1
- package/dist/styles.css +34 -0
- package/dist/task-ui-components.css +34 -0
- package/package.json +1 -1
- package/src/app-header.css +35 -0
- package/src/components/ConnectedSettings.tsx +175 -75
- package/src/lib/settingsClient.ts +20 -5
package/dist/app-header.css
CHANGED
|
@@ -77,6 +77,21 @@
|
|
|
77
77
|
display: inline-flex;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/* No passkey saved yet: a dot on the gear until one is. */
|
|
81
|
+
.settings-toggle-btn {
|
|
82
|
+
position: relative;
|
|
83
|
+
}
|
|
84
|
+
.settings-toggle-btn__dot {
|
|
85
|
+
position: absolute;
|
|
86
|
+
top: 2px;
|
|
87
|
+
right: 2px;
|
|
88
|
+
width: 8px;
|
|
89
|
+
height: 8px;
|
|
90
|
+
border-radius: 50%;
|
|
91
|
+
background: var(--color-primary);
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
/* Full-viewport click-catcher, same trick ThemePicker uses. */
|
|
81
96
|
.settings-popout__overlay {
|
|
82
97
|
position: fixed;
|
|
@@ -146,6 +161,26 @@
|
|
|
146
161
|
font-weight: 500;
|
|
147
162
|
color: var(--color-text);
|
|
148
163
|
}
|
|
164
|
+
/* The display name is its own edit control: click it to rename. No preflight
|
|
165
|
+
* here, so the button's UA chrome is reset explicitly. */
|
|
166
|
+
.settings-popout__value--editable {
|
|
167
|
+
background: transparent;
|
|
168
|
+
border: 1px solid transparent;
|
|
169
|
+
border-radius: 7px;
|
|
170
|
+
padding: 4px 8px;
|
|
171
|
+
margin-right: -8px;
|
|
172
|
+
font-family: inherit;
|
|
173
|
+
cursor: text;
|
|
174
|
+
transition: border-color 0.15s ease;
|
|
175
|
+
}
|
|
176
|
+
.settings-popout__value--editable:hover,
|
|
177
|
+
.settings-popout__value--editable:focus-visible {
|
|
178
|
+
border-color: var(--color-border);
|
|
179
|
+
outline: none;
|
|
180
|
+
}
|
|
181
|
+
.settings-popout__label--grow {
|
|
182
|
+
margin-right: auto;
|
|
183
|
+
}
|
|
149
184
|
|
|
150
185
|
/* Label + control pair on the right of a row. `min-width: 0` is load-bearing:
|
|
151
186
|
* as a flex item of __row it otherwise floors at its own min-content width, and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConnectedSettings.d.ts","sourceRoot":"","sources":["../../src/components/ConnectedSettings.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAmD,MAAM,OAAO,CAAA;AAEvE,OAAO,EAUL,KAAK,IAAI,EACV,MAAM,uBAAuB,CAAA;AAC9B,OAAO,mBAAmB,CAAA;AAE1B,MAAM,WAAW,sBAAsB;IACrC;;;;;;+EAM2E;IAC3E,QAAQ,CAAC,EAAE,IAAI,CAAA;IACf,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,mEAAmE;IACnE,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IAC5C;;;;;;8BAM0B;IAC1B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC3B;
|
|
1
|
+
{"version":3,"file":"ConnectedSettings.d.ts","sourceRoot":"","sources":["../../src/components/ConnectedSettings.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAmD,MAAM,OAAO,CAAA;AAEvE,OAAO,EAUL,KAAK,IAAI,EACV,MAAM,uBAAuB,CAAA;AAC9B,OAAO,mBAAmB,CAAA;AAE1B,MAAM,WAAW,sBAAsB;IACrC;;;;;;+EAM2E;IAC3E,QAAQ,CAAC,EAAE,IAAI,CAAA;IACf,uEAAuE;IACvE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,mEAAmE;IACnE,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAA;IAC5C;;;;;;8BAM0B;IAC1B,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAA;CAC3B;AA+BD,wBAAgB,iBAAiB,CAAC,EAChC,QAAQ,EAAE,YAAY,EACtB,IAAI,EAAE,QAAQ,EACd,YAAY,EACZ,QAAQ,EACT,EAAE,sBAAsB,qBAiiBxB"}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { jsxs, jsx, Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useRef,
|
|
2
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
3
3
|
import { SettingsIcon } from "./ThemeIcons.js";
|
|
4
4
|
import { prefetchSettings, setDisplayName, loadSignIn, switchKeyHref, loadSignInClient, passkeyHref, setContentLevel, passkeysSupported } from "../lib/settingsClient.js";
|
|
5
5
|
/* empty css */
|
|
6
|
+
function untilAborted(signal) {
|
|
7
|
+
return new Promise((_, reject) => {
|
|
8
|
+
const fail = () => reject(new Error("cancelled"));
|
|
9
|
+
if (signal.aborted) fail();
|
|
10
|
+
else signal.addEventListener("abort", fail, { once: true });
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
const CLOSED = "closed";
|
|
6
14
|
function reportErr(err) {
|
|
7
15
|
console.error("[ConnectedSettings] action failed", err?.message ?? err);
|
|
8
16
|
}
|
|
@@ -31,13 +39,17 @@ function ConnectedSettings({
|
|
|
31
39
|
const [keyDraft, setKeyDraft] = useState("");
|
|
32
40
|
const [keyBusy, setKeyBusy] = useState(false);
|
|
33
41
|
const [keyError, setKeyError] = useState(null);
|
|
34
|
-
const [
|
|
42
|
+
const [passkeyMode, setPasskeyMode] = useState(null);
|
|
35
43
|
const [passkeySaved, setPasskeySaved] = useState(false);
|
|
44
|
+
const [passkeyCount, setPasskeyCount] = useState(null);
|
|
36
45
|
const [passkeyError, setPasskeyError] = useState(null);
|
|
46
|
+
const passkeyCtl = useRef(null);
|
|
47
|
+
const cancelPasskey = useCallback((reason) => passkeyCtl.current?.abort(reason), []);
|
|
37
48
|
const [content, setContent] = useState(null);
|
|
38
49
|
const [levelSaving, setLevelSaving] = useState(false);
|
|
39
50
|
const [resolving, setResolving] = useState(true);
|
|
40
51
|
const showContentPill = userType !== "public";
|
|
52
|
+
const needsPasskey = !resolving && userType !== "public" && passkeyCount === 0 && !passkeySaved && passkeysSupported();
|
|
41
53
|
useEffect(() => {
|
|
42
54
|
if (userTypeProp !== void 0) setUserType(userTypeProp);
|
|
43
55
|
}, [userTypeProp]);
|
|
@@ -50,8 +62,9 @@ function ConnectedSettings({
|
|
|
50
62
|
}, [nameProp]);
|
|
51
63
|
useEffect(() => {
|
|
52
64
|
let live = true;
|
|
53
|
-
prefetchSettings({ userType: userTypeProp, name: nameProp }).then(({ identity, content: state }) => {
|
|
65
|
+
prefetchSettings({ userType: userTypeProp, name: nameProp }).then(({ identity, content: state, passkeys }) => {
|
|
54
66
|
if (!live) return;
|
|
67
|
+
setPasskeyCount(passkeys);
|
|
55
68
|
if (userTypeProp === void 0) {
|
|
56
69
|
setUserType(identity.userType);
|
|
57
70
|
setResolvedName(identity.name);
|
|
@@ -115,38 +128,67 @@ function ConnectedSettings({
|
|
|
115
128
|
window.location.reload();
|
|
116
129
|
}, [keyDraft]);
|
|
117
130
|
const savePasskey = useCallback(async () => {
|
|
118
|
-
|
|
131
|
+
const ctl = new AbortController();
|
|
132
|
+
passkeyCtl.current = ctl;
|
|
133
|
+
setPasskeyMode("save");
|
|
119
134
|
setPasskeyError(null);
|
|
120
|
-
const client = await loadSignInClient();
|
|
121
|
-
if (!client?.addPasskey) {
|
|
122
|
-
window.location.assign(passkeyHref("add"));
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
135
|
try {
|
|
126
|
-
await
|
|
136
|
+
const client = await Promise.race([loadSignInClient(), untilAborted(ctl.signal)]);
|
|
137
|
+
if (!client?.addPasskey) {
|
|
138
|
+
window.location.assign(passkeyHref("add"));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await Promise.race([client.addPasskey({ signal: ctl.signal }), untilAborted(ctl.signal)]);
|
|
127
142
|
setPasskeySaved(true);
|
|
128
143
|
} catch (err) {
|
|
129
|
-
|
|
144
|
+
if (ctl.signal.reason !== CLOSED) {
|
|
145
|
+
setPasskeyError(
|
|
146
|
+
ctl.signal.aborted ? "Passkey not saved" : err?.message || "Passkey not saved"
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
} finally {
|
|
150
|
+
if (passkeyCtl.current === ctl) passkeyCtl.current = null;
|
|
151
|
+
setPasskeyMode(null);
|
|
130
152
|
}
|
|
131
|
-
setPasskeyBusy(false);
|
|
132
153
|
}, []);
|
|
133
154
|
const signInWithPasskey = useCallback(async () => {
|
|
155
|
+
const ctl = new AbortController();
|
|
156
|
+
passkeyCtl.current = ctl;
|
|
157
|
+
setPasskeyMode("signin");
|
|
134
158
|
setKeyBusy(true);
|
|
135
159
|
setKeyError(null);
|
|
136
|
-
|
|
137
|
-
if (!client?.signInWithPasskey) {
|
|
138
|
-
window.location.assign(passkeyHref("signin"));
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
160
|
+
let leaving = false;
|
|
141
161
|
try {
|
|
142
|
-
await
|
|
162
|
+
const client = await Promise.race([loadSignInClient(), untilAborted(ctl.signal)]);
|
|
163
|
+
if (!client?.signInWithPasskey) {
|
|
164
|
+
leaving = true;
|
|
165
|
+
window.location.assign(passkeyHref("signin"));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
await Promise.race([
|
|
169
|
+
client.signInWithPasskey({ signal: ctl.signal }),
|
|
170
|
+
untilAborted(ctl.signal)
|
|
171
|
+
]);
|
|
172
|
+
leaving = true;
|
|
173
|
+
window.location.reload();
|
|
143
174
|
} catch (err) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
175
|
+
if (ctl.signal.reason !== CLOSED) {
|
|
176
|
+
setKeyError(
|
|
177
|
+
ctl.signal.aborted ? "Passkey sign-in cancelled" : err?.message || "Passkey sign-in cancelled"
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
} finally {
|
|
181
|
+
if (passkeyCtl.current === ctl) passkeyCtl.current = null;
|
|
182
|
+
if (!leaving) {
|
|
183
|
+
setPasskeyMode(null);
|
|
184
|
+
setKeyBusy(false);
|
|
185
|
+
}
|
|
147
186
|
}
|
|
148
|
-
window.location.reload();
|
|
149
187
|
}, []);
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!open) cancelPasskey(CLOSED);
|
|
190
|
+
}, [open, cancelPasskey]);
|
|
191
|
+
useEffect(() => () => cancelPasskey(CLOSED), [cancelPasskey]);
|
|
150
192
|
const pickLevel = useCallback(
|
|
151
193
|
async (level) => {
|
|
152
194
|
if (!content || level === content.level || levelSaving) return;
|
|
@@ -160,15 +202,18 @@ function ConnectedSettings({
|
|
|
160
202
|
[content, levelSaving]
|
|
161
203
|
);
|
|
162
204
|
return /* @__PURE__ */ jsxs("div", { className: "settings-popout", ref: rootRef, children: [
|
|
163
|
-
/* @__PURE__ */
|
|
205
|
+
/* @__PURE__ */ jsxs(
|
|
164
206
|
"button",
|
|
165
207
|
{
|
|
166
208
|
type: "button",
|
|
167
209
|
className: "app-header__icon-btn settings-toggle-btn",
|
|
168
|
-
"aria-label": "User settings",
|
|
210
|
+
"aria-label": needsPasskey ? "User settings, no passkey saved" : "User settings",
|
|
169
211
|
"aria-expanded": open,
|
|
170
212
|
onClick: () => setOpen((v) => !v),
|
|
171
|
-
children:
|
|
213
|
+
children: [
|
|
214
|
+
/* @__PURE__ */ jsx(SettingsIcon, {}),
|
|
215
|
+
needsPasskey && /* @__PURE__ */ jsx("span", { className: "settings-toggle-btn__dot", "aria-hidden": "true" })
|
|
216
|
+
]
|
|
172
217
|
}
|
|
173
218
|
),
|
|
174
219
|
open && /* @__PURE__ */ jsx("div", { className: "settings-popout__overlay", onClick: () => setOpen(false) }),
|
|
@@ -195,6 +240,7 @@ function ConnectedSettings({
|
|
|
195
240
|
saveName().catch(reportErr);
|
|
196
241
|
}
|
|
197
242
|
if (e.key === "Escape") {
|
|
243
|
+
e.stopPropagation();
|
|
198
244
|
setEditingName(false);
|
|
199
245
|
setNameDraft(displayName ?? "");
|
|
200
246
|
}
|
|
@@ -213,21 +259,19 @@ function ConnectedSettings({
|
|
|
213
259
|
children: nameSaving ? "…" : "Save"
|
|
214
260
|
}
|
|
215
261
|
)
|
|
216
|
-
] }) : /* @__PURE__ */
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
"button",
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
)
|
|
230
|
-
] })
|
|
262
|
+
] }) : /* @__PURE__ */ jsx(
|
|
263
|
+
"button",
|
|
264
|
+
{
|
|
265
|
+
type: "button",
|
|
266
|
+
className: "settings-popout__value settings-popout__value--editable",
|
|
267
|
+
"aria-label": "Edit display name",
|
|
268
|
+
onClick: () => {
|
|
269
|
+
setNameDraft(displayName ?? "");
|
|
270
|
+
setEditingName(true);
|
|
271
|
+
},
|
|
272
|
+
children: displayName || "Unnamed"
|
|
273
|
+
}
|
|
274
|
+
)
|
|
231
275
|
] }),
|
|
232
276
|
!resolving && showContentPill && /* @__PURE__ */ jsxs("section", { className: "settings-popout__row settings-popout__row--stack", children: [
|
|
233
277
|
/* @__PURE__ */ jsx("span", { className: "settings-popout__label", children: "Content visibility" }),
|
|
@@ -263,24 +307,6 @@ function ConnectedSettings({
|
|
|
263
307
|
/* @__PURE__ */ jsx("span", { className: "settings-popout__hint", children: "Currently unavailable." })
|
|
264
308
|
)
|
|
265
309
|
] }),
|
|
266
|
-
!resolving && userType !== "public" && passkeysSupported() && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
267
|
-
/* @__PURE__ */ jsxs("section", { className: "settings-popout__row", children: [
|
|
268
|
-
/* @__PURE__ */ jsx("span", { className: "settings-popout__label", children: "Passkey" }),
|
|
269
|
-
/* @__PURE__ */ jsx(
|
|
270
|
-
"button",
|
|
271
|
-
{
|
|
272
|
-
type: "button",
|
|
273
|
-
className: "settings-popout__btn",
|
|
274
|
-
disabled: passkeyBusy || passkeySaved,
|
|
275
|
-
onClick: () => {
|
|
276
|
-
savePasskey().catch(reportErr);
|
|
277
|
-
},
|
|
278
|
-
children: passkeySaved ? "Saved" : passkeyBusy ? "Saving…" : "Save"
|
|
279
|
-
}
|
|
280
|
-
)
|
|
281
|
-
] }),
|
|
282
|
-
passkeyError && /* @__PURE__ */ jsx("span", { className: "settings-popout__error", role: "alert", children: passkeyError })
|
|
283
|
-
] }),
|
|
284
310
|
keyFormOpen ? /* @__PURE__ */ jsx("section", { className: "settings-popout__row settings-popout__row--stack", children: /* @__PURE__ */ jsxs(
|
|
285
311
|
"form",
|
|
286
312
|
{
|
|
@@ -331,7 +357,7 @@ function ConnectedSettings({
|
|
|
331
357
|
}
|
|
332
358
|
)
|
|
333
359
|
] }),
|
|
334
|
-
passkeysSupported() && /* @__PURE__ */ jsx(
|
|
360
|
+
passkeysSupported() && /* @__PURE__ */ jsx("span", { className: "settings-popout__inline", children: /* @__PURE__ */ jsx(
|
|
335
361
|
"button",
|
|
336
362
|
{
|
|
337
363
|
type: "button",
|
|
@@ -340,9 +366,18 @@ function ConnectedSettings({
|
|
|
340
366
|
onClick: () => {
|
|
341
367
|
signInWithPasskey().catch(reportErr);
|
|
342
368
|
},
|
|
343
|
-
children: "Use a passkey"
|
|
369
|
+
children: passkeyMode === "signin" ? "Waiting for passkey…" : "Use a passkey"
|
|
344
370
|
}
|
|
345
|
-
),
|
|
371
|
+
) }),
|
|
372
|
+
passkeyMode === "signin" && /* @__PURE__ */ jsx("span", { className: "settings-popout__inline", children: /* @__PURE__ */ jsx(
|
|
373
|
+
"button",
|
|
374
|
+
{
|
|
375
|
+
type: "button",
|
|
376
|
+
className: "settings-popout__btn",
|
|
377
|
+
onClick: () => cancelPasskey(),
|
|
378
|
+
children: "Cancel"
|
|
379
|
+
}
|
|
380
|
+
) }),
|
|
346
381
|
keyError && /* @__PURE__ */ jsx(
|
|
347
382
|
"span",
|
|
348
383
|
{
|
|
@@ -354,9 +389,42 @@ function ConnectedSettings({
|
|
|
354
389
|
)
|
|
355
390
|
]
|
|
356
391
|
}
|
|
357
|
-
) }) : /* @__PURE__ */ jsxs("section", { className: "settings-popout__row", children: [
|
|
358
|
-
/* @__PURE__ */
|
|
359
|
-
|
|
392
|
+
) }) : /* @__PURE__ */ jsxs("section", { className: "settings-popout__row settings-popout__row--stack", children: [
|
|
393
|
+
/* @__PURE__ */ jsxs("span", { className: "settings-popout__inline", children: [
|
|
394
|
+
/* @__PURE__ */ jsx("span", { className: "settings-popout__label settings-popout__label--grow", children: "Access key" }),
|
|
395
|
+
!resolving && userType !== "public" && passkeysSupported() && /* @__PURE__ */ jsx(
|
|
396
|
+
"button",
|
|
397
|
+
{
|
|
398
|
+
type: "button",
|
|
399
|
+
className: `settings-popout__btn${needsPasskey ? " settings-popout__btn--primary" : ""}`,
|
|
400
|
+
disabled: passkeyMode !== null || passkeySaved,
|
|
401
|
+
onClick: () => {
|
|
402
|
+
savePasskey().catch(reportErr);
|
|
403
|
+
},
|
|
404
|
+
children: passkeySaved ? "Passkey saved" : passkeyMode === "save" ? "Saving…" : "Save passkey"
|
|
405
|
+
}
|
|
406
|
+
),
|
|
407
|
+
/* @__PURE__ */ jsx(
|
|
408
|
+
"button",
|
|
409
|
+
{
|
|
410
|
+
type: "button",
|
|
411
|
+
className: "settings-popout__btn",
|
|
412
|
+
disabled: passkeyMode === "save",
|
|
413
|
+
onClick: openKeyForm,
|
|
414
|
+
children: "Switch key"
|
|
415
|
+
}
|
|
416
|
+
)
|
|
417
|
+
] }),
|
|
418
|
+
passkeyMode === "save" && /* @__PURE__ */ jsx("span", { className: "settings-popout__inline", children: /* @__PURE__ */ jsx(
|
|
419
|
+
"button",
|
|
420
|
+
{
|
|
421
|
+
type: "button",
|
|
422
|
+
className: "settings-popout__btn",
|
|
423
|
+
onClick: () => cancelPasskey(),
|
|
424
|
+
children: "Cancel"
|
|
425
|
+
}
|
|
426
|
+
) }),
|
|
427
|
+
passkeyError && /* @__PURE__ */ jsx("span", { className: "settings-popout__error", role: "alert", children: passkeyError })
|
|
360
428
|
] }),
|
|
361
429
|
children && /* @__PURE__ */ jsx("section", { className: "settings-popout__row settings-popout__row--stack settings-popout__app", children })
|
|
362
430
|
] })
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ConnectedSettings.js","sources":["../../src/components/ConnectedSettings.tsx"],"sourcesContent":["/**\n * ConnectedSettings — the unified per-user settings control for the hadoku\n * ecosystem: a gear button that opens a popout housing everything that is a\n * property of the PERSON (not the app):\n * - current access tier (read-only, friendly label)\n * - display name (inline edit → POST /session/name)\n * - global content-visibility level (tiered pill → PUT /prefs/api/v1/content-level)\n * - switch key (an inline sign-in form, on the site's own sign-in client)\n *\n * \"Connected\" (parallel to ConnectedThemePicker): it self-wires to the\n * hadoku.me edge-router via same-origin fetches (see lib/settingsClient), so\n * dropping `<ConnectedSettings />` into any app under hadoku.me needs zero\n * props. Identity is resolved via whoami() unless the host passes `userType` /\n * `name` (avoids a redundant fetch when the app already knows).\n *\n * All of that is resolved AT MOUNT via `prefetchSettings()`, never on\n * gear-click — opening the popout must cost zero requests. See the effect\n * below, and `e2e/settings-prefetch.spec.ts` in hadoku-task, which fails if the\n * fetch moves back behind the click.\n *\n * Mirrors ThemePicker's controlled-popout pattern: owns its own `open` state +\n * a full-viewport overlay for click-outside dismiss. The content pill renders\n * `maxLevel` segments (friend ⇒ 3, admin ⇒ 4) and is hidden for public callers.\n * maxLevel comes from the server so the tier ceiling is never trusted from the\n * client.\n */\nimport React, { useCallback, useEffect, useRef, useState } from 'react'\nimport { SettingsIcon } from './ThemeIcons'\nimport {\n prefetchSettings,\n setContentLevel,\n setDisplayName,\n loadSignIn,\n loadSignInClient,\n passkeyHref,\n passkeysSupported,\n switchKeyHref,\n type ContentLevelState,\n type Tier\n} from '../lib/settingsClient'\nimport '../app-header.css'\n\nexport interface ConnectedSettingsProps {\n /** Caller's tier. Omit to self-resolve via whoami().\n *\n * This exists for apps that CANNOT reach `/session/whoami` — conjure sits\n * behind a path-prefixed shim that serves nothing outside `/conjure`, so\n * whoami 404s and the tier would fall back to guest. It is an identity\n * hint, not a way to present a different tier than the edge assigns: every\n * gate that matters is enforced server-side off the edge-stamped tier. */\n userType?: Tier\n /** Display name. Omit to self-resolve via whoami(). Same rationale. */\n name?: string | null\n /** Notify the host (e.g. a \"Signed in as …\" label) of a rename. */\n onNameChange?: (name: string | null) => void\n /** App-specific settings rendered as a final section in the same popout, so\n * an app with its own preferences keeps ONE unified gear instead of a second\n * control. Shown under an \"App preferences\" divider.\n *\n * This slot is deliberately unconstrained — put whatever the app needs in\n * it. The constraint is only that it lands AFTER the four canonical rows and\n * cannot change them. */\n children?: React.ReactNode\n}\n\n/** Rejection sink for fire-and-forget settings actions. The client helpers\n * already resolve to null on failure (state reflects it), so this only trips\n * on truly unexpected throws — log rather than swallow silently. */\nfunction reportErr(err: unknown) {\n // task-ui-components v2 dropped its logger; keep the package dependency-free.\n console.error('[ConnectedSettings] action failed', (err as Error)?.message ?? err)\n}\n\nconst TIER_LABEL: Record<Tier, string> = {\n admin: 'Administrator',\n wife: 'Wife',\n service: 'Service',\n friend: 'Friend',\n public: 'Guest'\n}\n\nexport function ConnectedSettings({\n userType: userTypeProp,\n name: nameProp,\n onNameChange,\n children\n}: ConnectedSettingsProps) {\n const [open, setOpen] = useState(false)\n const rootRef = useRef<HTMLDivElement>(null)\n\n // Identity — from props if supplied, else resolved lazily via whoami().\n const [userType, setUserType] = useState<Tier>(userTypeProp ?? 'public')\n const [resolvedName, setResolvedName] = useState<string | null>(nameProp ?? null)\n\n // Name editing\n const [nameDraft, setNameDraft] = useState(resolvedName ?? '')\n const [editingName, setEditingName] = useState(false)\n const [nameSaving, setNameSaving] = useState(false)\n const [displayName, setLocalName] = useState<string | null>(resolvedName)\n\n // Key swap. The draft lives only in this state and the input: it is never\n // logged, stored or sent anywhere but the site's sign-in client.\n const [keyFormOpen, setKeyFormOpen] = useState(false)\n const [keyDraft, setKeyDraft] = useState('')\n const [keyBusy, setKeyBusy] = useState(false)\n const [keyError, setKeyError] = useState<string | null>(null)\n\n // Passkeys, through the same site client: save one while signed in, or\n // sign in with one from the key form.\n const [passkeyBusy, setPasskeyBusy] = useState(false)\n const [passkeySaved, setPasskeySaved] = useState(false)\n const [passkeyError, setPasskeyError] = useState<string | null>(null)\n\n // Content level\n const [content, setContent] = useState<ContentLevelState | null>(null)\n const [levelSaving, setLevelSaving] = useState(false)\n\n // One flag for the whole prefetch, not one per request — the panel body\n // reveals when everything has settled, so nothing pops in a beat late. In\n // practice the user never sees it: the prefetch starts at mount and is long\n // done by the time the gear is clicked. It only shows if they click within\n // the first moments of the page.\n const [resolving, setResolving] = useState(true)\n\n // Everyone who is SIGNED IN, expressed as \"not public\" rather than as a list\n // of tiers. The list form (`=== 'admin' || === 'friend' || === 'service'`)\n // is an exact-match allowlist: it silently excluded `wife` the moment that\n // tier existed, which is the failure the rank model is meant to prevent.\n // There is no rank helper in this bundle, but \"above public\" needs none.\n const showContentPill = userType !== 'public'\n\n // Keep local identity in sync when the host controls it via props.\n useEffect(() => {\n if (userTypeProp !== undefined) setUserType(userTypeProp)\n }, [userTypeProp])\n useEffect(() => {\n if (nameProp !== undefined) {\n setResolvedName(nameProp)\n setLocalName(nameProp)\n setNameDraft(nameProp ?? '')\n }\n }, [nameProp])\n\n // Resolve everything the panel shows AT MOUNT — not on open.\n //\n // This used to be gated on `open`, which made the gear the trigger for\n // whoami + content-level (167ms / 186ms on prod, concurrent) — a round trip\n // the user waited out with the panel already on screen and blank. Nothing\n // about that data depends on the click:\n // it's the signed-in user's own identity, settled at boot and unchanged for\n // the life of the page. So it rides along with the app's boot traffic, and\n // clicking the gear costs zero requests. `e2e/settings-prefetch.spec.ts` in\n // hadoku-task pins that.\n //\n // The client memoises across callers, so mounting this in several places (or\n // remounting it) does not multiply requests, and on hadoku.me the identity\n // half is free — it awaits the whoami the shell already had in flight.\n useEffect(() => {\n let live = true\n prefetchSettings({ userType: userTypeProp, name: nameProp })\n .then(({ identity, content: state }) => {\n if (!live) return\n // Props stay authoritative: the host passed them because it can see an\n // identity this component can't resolve for itself.\n if (userTypeProp === undefined) {\n setUserType(identity.userType)\n setResolvedName(identity.name)\n setLocalName(identity.name)\n setNameDraft(identity.name ?? '')\n }\n if (state) setContent(state)\n })\n .catch(reportErr)\n .finally(() => {\n if (live) setResolving(false)\n })\n return () => {\n live = false\n }\n // Hints are read once, on the first mount that gets there — the prefetch is\n // page-scoped and a later prop change cannot un-issue it. Identity changes\n // arrive through the sync effects above, and a key swap reloads the page.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n // Escape closes.\n useEffect(() => {\n if (!open) return\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') setOpen(false)\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [open])\n\n const saveName = useCallback(async () => {\n const trimmed = nameDraft.trim()\n if (!trimmed || trimmed === displayName) {\n setEditingName(false)\n return\n }\n setNameSaving(true)\n const stored = await setDisplayName(trimmed)\n setNameSaving(false)\n if (stored !== null) {\n setLocalName(stored)\n setNameDraft(stored)\n onNameChange?.(stored)\n }\n setEditingName(false)\n }, [nameDraft, displayName, onNameChange])\n\n // Open the form AND start loading the site's sign-in client, which begins its\n // fingerprint collection as it evaluates — so it is ready by submit. Where the\n // client cannot load at all (not on hadoku.me), the page-level /auth flow is\n // the only way to sign in, so go there.\n const openKeyForm = useCallback(() => {\n setKeyFormOpen(true)\n setKeyError(null)\n loadSignIn()\n .then(signIn => {\n if (!signIn) window.location.assign(switchKeyHref())\n })\n .catch(reportErr)\n }, [])\n\n const submitKey = useCallback(async () => {\n setKeyBusy(true)\n setKeyError(null)\n const signIn = await loadSignIn()\n if (!signIn) {\n window.location.assign(switchKeyHref())\n return\n }\n try {\n await signIn(keyDraft)\n } catch (err) {\n // A refused key is an inline error, never a redirect.\n setKeyError((err as Error)?.message || 'Invalid key')\n setKeyBusy(false)\n return\n }\n // The page now acts for a different identity: everything must re-mount.\n // Busy stays on through the reload so the form can't be resubmitted.\n window.location.reload()\n }, [keyDraft])\n\n const savePasskey = useCallback(async () => {\n setPasskeyBusy(true)\n setPasskeyError(null)\n const client = await loadSignInClient()\n if (!client?.addPasskey) {\n window.location.assign(passkeyHref('add'))\n return\n }\n try {\n await client.addPasskey()\n setPasskeySaved(true)\n } catch (err) {\n setPasskeyError((err as Error)?.message || 'Passkey not saved')\n }\n setPasskeyBusy(false)\n }, [])\n\n const signInWithPasskey = useCallback(async () => {\n setKeyBusy(true)\n setKeyError(null)\n const client = await loadSignInClient()\n if (!client?.signInWithPasskey) {\n window.location.assign(passkeyHref('signin'))\n return\n }\n try {\n await client.signInWithPasskey()\n } catch (err) {\n setKeyError((err as Error)?.message || 'Passkey sign-in cancelled')\n setKeyBusy(false)\n return\n }\n window.location.reload()\n }, [])\n\n const pickLevel = useCallback(\n async (level: number) => {\n if (!content || level === content.level || levelSaving) return\n const prev = content\n setContent({ ...content, level }) // optimistic\n setLevelSaving(true)\n const result = await setContentLevel(level)\n setLevelSaving(false)\n setContent(result ?? prev) // adopt server truth, or revert\n },\n [content, levelSaving]\n )\n\n // No caller-supplied class on the root. The four rows below are the\n // platform's, not the app's, and a className here is enough to restyle or\n // hide any of them — which is the drift this component exists to prevent.\n // Apps extend via `children`, never by reaching into this markup.\n return (\n <div className=\"settings-popout\" ref={rootRef}>\n <button\n type=\"button\"\n className=\"app-header__icon-btn settings-toggle-btn\"\n aria-label=\"User settings\"\n aria-expanded={open}\n onClick={() => setOpen(v => !v)}\n >\n <SettingsIcon />\n </button>\n\n {open && <div className=\"settings-popout__overlay\" onClick={() => setOpen(false)} />}\n\n {open && (\n <div className=\"settings-popout__panel\" role=\"dialog\" aria-label=\"User settings\">\n <div className=\"settings-popout__header\">Settings</div>\n\n {/* Access tier. While resolving, the state still says 'public' —\n rendering \"Guest\" there would be a wrong answer that corrects\n itself a moment later, which reads exactly like the lag this\n placeholder exists to remove. */}\n <section className=\"settings-popout__row\">\n <span className=\"settings-popout__label\">Access tier</span>\n {resolving ? (\n <span className=\"settings-popout__value\">…</span>\n ) : (\n <span className={`settings-popout__tier settings-popout__tier--${userType}`}>\n {TIER_LABEL[userType]}\n </span>\n )}\n </section>\n\n {/* Display name */}\n <section className=\"settings-popout__row\">\n <span className=\"settings-popout__label\">Display name</span>\n {resolving ? (\n <span className=\"settings-popout__value\">…</span>\n ) : editingName ? (\n <span className=\"settings-popout__inline\">\n <input\n className=\"settings-popout__input\"\n value={nameDraft}\n maxLength={64}\n autoFocus\n disabled={nameSaving}\n onChange={e => setNameDraft(e.target.value)}\n onKeyDown={e => {\n if (e.key === 'Enter') {\n saveName().catch(reportErr)\n }\n if (e.key === 'Escape') {\n setEditingName(false)\n setNameDraft(displayName ?? '')\n }\n }}\n />\n <button\n type=\"button\"\n className=\"settings-popout__btn settings-popout__btn--primary\"\n disabled={nameSaving}\n onClick={() => {\n saveName().catch(reportErr)\n }}\n >\n {nameSaving ? '…' : 'Save'}\n </button>\n </span>\n ) : (\n <span className=\"settings-popout__inline\">\n <span className=\"settings-popout__value\">{displayName || 'Unnamed'}</span>\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n onClick={() => {\n setNameDraft(displayName ?? '')\n setEditingName(true)\n }}\n >\n Edit\n </button>\n </span>\n )}\n </section>\n\n {/* Content visibility level. Held back until the first-open fetch\n settles, so it lands in the same paint as the tier and name\n rather than a round trip behind them. `content` is fetched\n concurrently with whoami(), so by then it is already here. */}\n {!resolving && showContentPill && (\n <section className=\"settings-popout__row settings-popout__row--stack\">\n <span className=\"settings-popout__label\">Content visibility</span>\n {content ? (\n <>\n <div\n className=\"settings-popout__pill\"\n role=\"radiogroup\"\n aria-label=\"Content visibility level\"\n >\n {Array.from({ length: content.maxLevel }, (_, i) => i + 1).map(lvl => (\n <button\n key={lvl}\n type=\"button\"\n role=\"radio\"\n aria-checked={lvl === content.level}\n aria-label={`Level ${lvl}`}\n className={`settings-popout__seg${\n lvl <= content.level ? ' settings-popout__seg--filled' : ''\n }`}\n disabled={levelSaving}\n onClick={() => {\n pickLevel(lvl).catch(reportErr)\n }}\n >\n {lvl}\n </button>\n ))}\n </div>\n <span className=\"settings-popout__hint\">\n Higher levels reveal more mature content across apps. Level 1 is\n safe-for-everyone.\n </span>\n </>\n ) : (\n // Not \"Loading…\" — the fetch has already settled by the time\n // this section renders at all, so a null here means it failed.\n <span className=\"settings-popout__hint\">Currently unavailable.</span>\n )}\n </section>\n )}\n\n {/* Save a passkey: signed-in people only (the site refuses others). */}\n {!resolving && userType !== 'public' && passkeysSupported() && (\n <>\n <section className=\"settings-popout__row\">\n <span className=\"settings-popout__label\">Passkey</span>\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={passkeyBusy || passkeySaved}\n onClick={() => {\n savePasskey().catch(reportErr)\n }}\n >\n {passkeySaved ? 'Saved' : passkeyBusy ? 'Saving…' : 'Save'}\n </button>\n </section>\n {passkeyError && (\n <span className=\"settings-popout__error\" role=\"alert\">\n {passkeyError}\n </span>\n )}\n </>\n )}\n\n {/* Switch key. Signs in inline through the SITE's sign-in client\n (the /auth page's own code path), never a post of ours. A real\n <form> with a username + current-password pair, so password\n managers offer and save the key. */}\n {keyFormOpen ? (\n <section className=\"settings-popout__row settings-popout__row--stack\">\n <form\n className=\"settings-popout__key-form\"\n onSubmit={e => {\n e.preventDefault()\n submitKey().catch(reportErr)\n }}\n >\n <input\n type=\"text\"\n name=\"username\"\n autoComplete=\"username\"\n value=\"hadoku\"\n readOnly\n hidden\n />\n <label className=\"settings-popout__label\" htmlFor=\"settings-popout-key\">\n Access key\n </label>\n <span className=\"settings-popout__inline\">\n <input\n id=\"settings-popout-key\"\n className=\"settings-popout__input\"\n type=\"password\"\n name=\"password\"\n autoComplete=\"current-password\"\n autoCapitalize=\"none\"\n spellCheck={false}\n autoFocus\n value={keyDraft}\n disabled={keyBusy}\n aria-invalid={keyError !== null}\n aria-describedby={keyError ? 'settings-popout-key-error' : undefined}\n onChange={e => setKeyDraft(e.target.value)}\n />\n <button\n type=\"submit\"\n className=\"settings-popout__btn settings-popout__btn--primary\"\n disabled={keyBusy}\n >\n {keyBusy ? 'Signing in…' : 'Sign in'}\n </button>\n </span>\n {passkeysSupported() && (\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={keyBusy}\n onClick={() => {\n signInWithPasskey().catch(reportErr)\n }}\n >\n Use a passkey\n </button>\n )}\n {keyError && (\n <span\n id=\"settings-popout-key-error\"\n className=\"settings-popout__error\"\n role=\"alert\"\n >\n {keyError}\n </span>\n )}\n </form>\n </section>\n ) : (\n <section className=\"settings-popout__row\">\n <span className=\"settings-popout__label\">Access key</span>\n <button type=\"button\" className=\"settings-popout__btn\" onClick={openKeyForm}>\n Switch key\n </button>\n </section>\n )}\n\n {/* App-specific settings (optional) — same popout, one gear. */}\n {children && (\n <section className=\"settings-popout__row settings-popout__row--stack settings-popout__app\">\n {children}\n </section>\n )}\n </div>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"ConnectedSettings.js","sources":["../../src/components/ConnectedSettings.tsx"],"sourcesContent":["/**\n * ConnectedSettings — the unified per-user settings control for the hadoku\n * ecosystem: a gear button that opens a popout housing everything that is a\n * property of the PERSON (not the app):\n * - current access tier (read-only, friendly label)\n * - display name (inline edit → POST /session/name)\n * - global content-visibility level (tiered pill → PUT /prefs/api/v1/content-level)\n * - switch key (an inline sign-in form, on the site's own sign-in client)\n *\n * \"Connected\" (parallel to ConnectedThemePicker): it self-wires to the\n * hadoku.me edge-router via same-origin fetches (see lib/settingsClient), so\n * dropping `<ConnectedSettings />` into any app under hadoku.me needs zero\n * props. Identity is resolved via whoami() unless the host passes `userType` /\n * `name` (avoids a redundant fetch when the app already knows).\n *\n * All of that is resolved AT MOUNT via `prefetchSettings()`, never on\n * gear-click — opening the popout must cost zero requests. See the effect\n * below, and `e2e/settings-prefetch.spec.ts` in hadoku-task, which fails if the\n * fetch moves back behind the click.\n *\n * Mirrors ThemePicker's controlled-popout pattern: owns its own `open` state +\n * a full-viewport overlay for click-outside dismiss. The content pill renders\n * `maxLevel` segments (friend ⇒ 3, admin ⇒ 4) and is hidden for public callers.\n * maxLevel comes from the server so the tier ceiling is never trusted from the\n * client.\n */\nimport React, { useCallback, useEffect, useRef, useState } from 'react'\nimport { SettingsIcon } from './ThemeIcons'\nimport {\n prefetchSettings,\n setContentLevel,\n setDisplayName,\n loadSignIn,\n loadSignInClient,\n passkeyHref,\n passkeysSupported,\n switchKeyHref,\n type ContentLevelState,\n type Tier\n} from '../lib/settingsClient'\nimport '../app-header.css'\n\nexport interface ConnectedSettingsProps {\n /** Caller's tier. Omit to self-resolve via whoami().\n *\n * This exists for apps that CANNOT reach `/session/whoami` — conjure sits\n * behind a path-prefixed shim that serves nothing outside `/conjure`, so\n * whoami 404s and the tier would fall back to guest. It is an identity\n * hint, not a way to present a different tier than the edge assigns: every\n * gate that matters is enforced server-side off the edge-stamped tier. */\n userType?: Tier\n /** Display name. Omit to self-resolve via whoami(). Same rationale. */\n name?: string | null\n /** Notify the host (e.g. a \"Signed in as …\" label) of a rename. */\n onNameChange?: (name: string | null) => void\n /** App-specific settings rendered as a final section in the same popout, so\n * an app with its own preferences keeps ONE unified gear instead of a second\n * control. Shown under an \"App preferences\" divider.\n *\n * This slot is deliberately unconstrained — put whatever the app needs in\n * it. The constraint is only that it lands AFTER the four canonical rows and\n * cannot change them. */\n children?: React.ReactNode\n}\n\n/** Rejects once `signal` aborts, so a call that ignores the signal (an older\n * site client, a prompt that never settles) still lets the UI go. */\nfunction untilAborted(signal: AbortSignal): Promise<never> {\n return new Promise((_, reject) => {\n const fail = () => reject(new Error('cancelled'))\n if (signal.aborted) fail()\n else signal.addEventListener('abort', fail, { once: true })\n })\n}\n\n/** Abort reason when the popout closes: a dismissal, not an error to show. */\nconst CLOSED = 'closed'\n\n/** Rejection sink for fire-and-forget settings actions. The client helpers\n * already resolve to null on failure (state reflects it), so this only trips\n * on truly unexpected throws — log rather than swallow silently. */\nfunction reportErr(err: unknown) {\n // task-ui-components v2 dropped its logger; keep the package dependency-free.\n console.error('[ConnectedSettings] action failed', (err as Error)?.message ?? err)\n}\n\nconst TIER_LABEL: Record<Tier, string> = {\n admin: 'Administrator',\n wife: 'Wife',\n service: 'Service',\n friend: 'Friend',\n public: 'Guest'\n}\n\nexport function ConnectedSettings({\n userType: userTypeProp,\n name: nameProp,\n onNameChange,\n children\n}: ConnectedSettingsProps) {\n const [open, setOpen] = useState(false)\n const rootRef = useRef<HTMLDivElement>(null)\n\n // Identity — from props if supplied, else resolved lazily via whoami().\n const [userType, setUserType] = useState<Tier>(userTypeProp ?? 'public')\n const [resolvedName, setResolvedName] = useState<string | null>(nameProp ?? null)\n\n // Name editing\n const [nameDraft, setNameDraft] = useState(resolvedName ?? '')\n const [editingName, setEditingName] = useState(false)\n const [nameSaving, setNameSaving] = useState(false)\n const [displayName, setLocalName] = useState<string | null>(resolvedName)\n\n // Key swap. The draft lives only in this state and the input: it is never\n // logged, stored or sent anywhere but the site's sign-in client.\n const [keyFormOpen, setKeyFormOpen] = useState(false)\n const [keyDraft, setKeyDraft] = useState('')\n const [keyBusy, setKeyBusy] = useState(false)\n const [keyError, setKeyError] = useState<string | null>(null)\n\n // Passkeys, through the same site client: save one while signed in, or\n // sign in with one from the key form. One prompt at a time, and always\n // cancellable: a password manager can dismiss the browser's prompt without\n // ever answering it, so Cancel, closing the popout and the site client's own\n // time limit each end the wait.\n const [passkeyMode, setPasskeyMode] = useState<'save' | 'signin' | null>(null)\n const [passkeySaved, setPasskeySaved] = useState(false)\n // How many passkeys sign this person in, from whoami; null = unknown.\n const [passkeyCount, setPasskeyCount] = useState<number | null>(null)\n const [passkeyError, setPasskeyError] = useState<string | null>(null)\n const passkeyCtl = useRef<AbortController | null>(null)\n const cancelPasskey = useCallback((reason?: string) => passkeyCtl.current?.abort(reason), [])\n\n // Content level\n const [content, setContent] = useState<ContentLevelState | null>(null)\n const [levelSaving, setLevelSaving] = useState(false)\n\n // One flag for the whole prefetch, not one per request — the panel body\n // reveals when everything has settled, so nothing pops in a beat late. In\n // practice the user never sees it: the prefetch starts at mount and is long\n // done by the time the gear is clicked. It only shows if they click within\n // the first moments of the page.\n const [resolving, setResolving] = useState(true)\n\n // Everyone who is SIGNED IN, expressed as \"not public\" rather than as a list\n // of tiers. The list form (`=== 'admin' || === 'friend' || === 'service'`)\n // is an exact-match allowlist: it silently excluded `wife` the moment that\n // tier existed, which is the failure the rank model is meant to prevent.\n // There is no rank helper in this bundle, but \"above public\" needs none.\n const showContentPill = userType !== 'public'\n\n // Signed in on a key with no passkey: the gear carries a dot and Save passkey\n // is the primary action until one is saved. Unknown counts never prompt.\n const needsPasskey =\n !resolving &&\n userType !== 'public' &&\n passkeyCount === 0 &&\n !passkeySaved &&\n passkeysSupported()\n\n // Keep local identity in sync when the host controls it via props.\n useEffect(() => {\n if (userTypeProp !== undefined) setUserType(userTypeProp)\n }, [userTypeProp])\n useEffect(() => {\n if (nameProp !== undefined) {\n setResolvedName(nameProp)\n setLocalName(nameProp)\n setNameDraft(nameProp ?? '')\n }\n }, [nameProp])\n\n // Resolve everything the panel shows AT MOUNT — not on open.\n //\n // This used to be gated on `open`, which made the gear the trigger for\n // whoami + content-level (167ms / 186ms on prod, concurrent) — a round trip\n // the user waited out with the panel already on screen and blank. Nothing\n // about that data depends on the click:\n // it's the signed-in user's own identity, settled at boot and unchanged for\n // the life of the page. So it rides along with the app's boot traffic, and\n // clicking the gear costs zero requests. `e2e/settings-prefetch.spec.ts` in\n // hadoku-task pins that.\n //\n // The client memoises across callers, so mounting this in several places (or\n // remounting it) does not multiply requests, and on hadoku.me the identity\n // half is free — it awaits the whoami the shell already had in flight.\n useEffect(() => {\n let live = true\n prefetchSettings({ userType: userTypeProp, name: nameProp })\n .then(({ identity, content: state, passkeys }) => {\n if (!live) return\n setPasskeyCount(passkeys)\n // Props stay authoritative: the host passed them because it can see an\n // identity this component can't resolve for itself.\n if (userTypeProp === undefined) {\n setUserType(identity.userType)\n setResolvedName(identity.name)\n setLocalName(identity.name)\n setNameDraft(identity.name ?? '')\n }\n if (state) setContent(state)\n })\n .catch(reportErr)\n .finally(() => {\n if (live) setResolving(false)\n })\n return () => {\n live = false\n }\n // Hints are read once, on the first mount that gets there — the prefetch is\n // page-scoped and a later prop change cannot un-issue it. Identity changes\n // arrive through the sync effects above, and a key swap reloads the page.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [])\n\n // Escape closes.\n useEffect(() => {\n if (!open) return\n const onKey = (e: KeyboardEvent) => {\n if (e.key === 'Escape') setOpen(false)\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [open])\n\n const saveName = useCallback(async () => {\n const trimmed = nameDraft.trim()\n if (!trimmed || trimmed === displayName) {\n setEditingName(false)\n return\n }\n setNameSaving(true)\n const stored = await setDisplayName(trimmed)\n setNameSaving(false)\n if (stored !== null) {\n setLocalName(stored)\n setNameDraft(stored)\n onNameChange?.(stored)\n }\n setEditingName(false)\n }, [nameDraft, displayName, onNameChange])\n\n // Open the form AND start loading the site's sign-in client, which begins its\n // fingerprint collection as it evaluates — so it is ready by submit. Where the\n // client cannot load at all (not on hadoku.me), the page-level /auth flow is\n // the only way to sign in, so go there.\n const openKeyForm = useCallback(() => {\n setKeyFormOpen(true)\n setKeyError(null)\n loadSignIn()\n .then(signIn => {\n if (!signIn) window.location.assign(switchKeyHref())\n })\n .catch(reportErr)\n }, [])\n\n const submitKey = useCallback(async () => {\n setKeyBusy(true)\n setKeyError(null)\n const signIn = await loadSignIn()\n if (!signIn) {\n window.location.assign(switchKeyHref())\n return\n }\n try {\n await signIn(keyDraft)\n } catch (err) {\n // A refused key is an inline error, never a redirect.\n setKeyError((err as Error)?.message || 'Invalid key')\n setKeyBusy(false)\n return\n }\n // The page now acts for a different identity: everything must re-mount.\n // Busy stays on through the reload so the form can't be resubmitted.\n window.location.reload()\n }, [keyDraft])\n\n const savePasskey = useCallback(async () => {\n const ctl = new AbortController()\n passkeyCtl.current = ctl\n setPasskeyMode('save')\n setPasskeyError(null)\n try {\n const client = await Promise.race([loadSignInClient(), untilAborted(ctl.signal)])\n if (!client?.addPasskey) {\n window.location.assign(passkeyHref('add'))\n return\n }\n await Promise.race([client.addPasskey({ signal: ctl.signal }), untilAborted(ctl.signal)])\n setPasskeySaved(true)\n } catch (err) {\n if (ctl.signal.reason !== CLOSED) {\n setPasskeyError(\n ctl.signal.aborted ? 'Passkey not saved' : (err as Error)?.message || 'Passkey not saved'\n )\n }\n } finally {\n if (passkeyCtl.current === ctl) passkeyCtl.current = null\n setPasskeyMode(null)\n }\n }, [])\n\n const signInWithPasskey = useCallback(async () => {\n const ctl = new AbortController()\n passkeyCtl.current = ctl\n setPasskeyMode('signin')\n setKeyBusy(true)\n setKeyError(null)\n let leaving = false\n try {\n const client = await Promise.race([loadSignInClient(), untilAborted(ctl.signal)])\n if (!client?.signInWithPasskey) {\n leaving = true\n window.location.assign(passkeyHref('signin'))\n return\n }\n await Promise.race([\n client.signInWithPasskey({ signal: ctl.signal }),\n untilAborted(ctl.signal)\n ])\n // The page now acts for a different identity: everything must re-mount.\n // Busy stays on through the reload so nothing can be resubmitted.\n leaving = true\n window.location.reload()\n } catch (err) {\n if (ctl.signal.reason !== CLOSED) {\n setKeyError(\n ctl.signal.aborted\n ? 'Passkey sign-in cancelled'\n : (err as Error)?.message || 'Passkey sign-in cancelled'\n )\n }\n } finally {\n if (passkeyCtl.current === ctl) passkeyCtl.current = null\n if (!leaving) {\n setPasskeyMode(null)\n setKeyBusy(false)\n }\n }\n }, [])\n\n // Closing the popout (or leaving the page) abandons an open prompt.\n useEffect(() => {\n if (!open) cancelPasskey(CLOSED)\n }, [open, cancelPasskey])\n useEffect(() => () => cancelPasskey(CLOSED), [cancelPasskey])\n\n const pickLevel = useCallback(\n async (level: number) => {\n if (!content || level === content.level || levelSaving) return\n const prev = content\n setContent({ ...content, level }) // optimistic\n setLevelSaving(true)\n const result = await setContentLevel(level)\n setLevelSaving(false)\n setContent(result ?? prev) // adopt server truth, or revert\n },\n [content, levelSaving]\n )\n\n // No caller-supplied class on the root. The four rows below are the\n // platform's, not the app's, and a className here is enough to restyle or\n // hide any of them — which is the drift this component exists to prevent.\n // Apps extend via `children`, never by reaching into this markup.\n return (\n <div className=\"settings-popout\" ref={rootRef}>\n <button\n type=\"button\"\n className=\"app-header__icon-btn settings-toggle-btn\"\n aria-label={needsPasskey ? 'User settings, no passkey saved' : 'User settings'}\n aria-expanded={open}\n onClick={() => setOpen(v => !v)}\n >\n <SettingsIcon />\n {needsPasskey && <span className=\"settings-toggle-btn__dot\" aria-hidden=\"true\" />}\n </button>\n\n {open && <div className=\"settings-popout__overlay\" onClick={() => setOpen(false)} />}\n\n {open && (\n <div className=\"settings-popout__panel\" role=\"dialog\" aria-label=\"User settings\">\n <div className=\"settings-popout__header\">Settings</div>\n\n {/* Access tier. While resolving, the state still says 'public' —\n rendering \"Guest\" there would be a wrong answer that corrects\n itself a moment later, which reads exactly like the lag this\n placeholder exists to remove. */}\n <section className=\"settings-popout__row\">\n <span className=\"settings-popout__label\">Access tier</span>\n {resolving ? (\n <span className=\"settings-popout__value\">…</span>\n ) : (\n <span className={`settings-popout__tier settings-popout__tier--${userType}`}>\n {TIER_LABEL[userType]}\n </span>\n )}\n </section>\n\n {/* Display name */}\n <section className=\"settings-popout__row\">\n <span className=\"settings-popout__label\">Display name</span>\n {resolving ? (\n <span className=\"settings-popout__value\">…</span>\n ) : editingName ? (\n <span className=\"settings-popout__inline\">\n <input\n className=\"settings-popout__input\"\n value={nameDraft}\n maxLength={64}\n autoFocus\n disabled={nameSaving}\n onChange={e => setNameDraft(e.target.value)}\n onKeyDown={e => {\n if (e.key === 'Enter') {\n saveName().catch(reportErr)\n }\n if (e.key === 'Escape') {\n // The edit's Escape, not the popout's (window listener).\n e.stopPropagation()\n setEditingName(false)\n setNameDraft(displayName ?? '')\n }\n }}\n />\n <button\n type=\"button\"\n className=\"settings-popout__btn settings-popout__btn--primary\"\n disabled={nameSaving}\n onClick={() => {\n saveName().catch(reportErr)\n }}\n >\n {nameSaving ? '…' : 'Save'}\n </button>\n </span>\n ) : (\n <button\n type=\"button\"\n className=\"settings-popout__value settings-popout__value--editable\"\n aria-label=\"Edit display name\"\n onClick={() => {\n setNameDraft(displayName ?? '')\n setEditingName(true)\n }}\n >\n {displayName || 'Unnamed'}\n </button>\n )}\n </section>\n\n {/* Content visibility level. Held back until the first-open fetch\n settles, so it lands in the same paint as the tier and name\n rather than a round trip behind them. `content` is fetched\n concurrently with whoami(), so by then it is already here. */}\n {!resolving && showContentPill && (\n <section className=\"settings-popout__row settings-popout__row--stack\">\n <span className=\"settings-popout__label\">Content visibility</span>\n {content ? (\n <>\n <div\n className=\"settings-popout__pill\"\n role=\"radiogroup\"\n aria-label=\"Content visibility level\"\n >\n {Array.from({ length: content.maxLevel }, (_, i) => i + 1).map(lvl => (\n <button\n key={lvl}\n type=\"button\"\n role=\"radio\"\n aria-checked={lvl === content.level}\n aria-label={`Level ${lvl}`}\n className={`settings-popout__seg${\n lvl <= content.level ? ' settings-popout__seg--filled' : ''\n }`}\n disabled={levelSaving}\n onClick={() => {\n pickLevel(lvl).catch(reportErr)\n }}\n >\n {lvl}\n </button>\n ))}\n </div>\n <span className=\"settings-popout__hint\">\n Higher levels reveal more mature content across apps. Level 1 is\n safe-for-everyone.\n </span>\n </>\n ) : (\n // Not \"Loading…\" — the fetch has already settled by the time\n // this section renders at all, so a null here means it failed.\n <span className=\"settings-popout__hint\">Currently unavailable.</span>\n )}\n </section>\n )}\n\n {/* Switch key. Signs in inline through the SITE's sign-in client\n (the /auth page's own code path), never a post of ours. A real\n <form> with a username + current-password pair, so password\n managers offer and save the key. */}\n {keyFormOpen ? (\n <section className=\"settings-popout__row settings-popout__row--stack\">\n <form\n className=\"settings-popout__key-form\"\n onSubmit={e => {\n e.preventDefault()\n submitKey().catch(reportErr)\n }}\n >\n <input\n type=\"text\"\n name=\"username\"\n autoComplete=\"username\"\n value=\"hadoku\"\n readOnly\n hidden\n />\n <label className=\"settings-popout__label\" htmlFor=\"settings-popout-key\">\n Access key\n </label>\n <span className=\"settings-popout__inline\">\n <input\n id=\"settings-popout-key\"\n className=\"settings-popout__input\"\n type=\"password\"\n name=\"password\"\n autoComplete=\"current-password\"\n autoCapitalize=\"none\"\n spellCheck={false}\n autoFocus\n value={keyDraft}\n disabled={keyBusy}\n aria-invalid={keyError !== null}\n aria-describedby={keyError ? 'settings-popout-key-error' : undefined}\n onChange={e => setKeyDraft(e.target.value)}\n />\n <button\n type=\"submit\"\n className=\"settings-popout__btn settings-popout__btn--primary\"\n disabled={keyBusy}\n >\n {keyBusy ? 'Signing in…' : 'Sign in'}\n </button>\n </span>\n {passkeysSupported() && (\n <span className=\"settings-popout__inline\">\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={keyBusy}\n onClick={() => {\n signInWithPasskey().catch(reportErr)\n }}\n >\n {passkeyMode === 'signin' ? 'Waiting for passkey…' : 'Use a passkey'}\n </button>\n </span>\n )}\n {/* Cancel on a line of its own: in the row it would slide under\n the pointer, and a double click would cancel what it started. */}\n {passkeyMode === 'signin' && (\n <span className=\"settings-popout__inline\">\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n onClick={() => cancelPasskey()}\n >\n Cancel\n </button>\n </span>\n )}\n {keyError && (\n <span\n id=\"settings-popout-key-error\"\n className=\"settings-popout__error\"\n role=\"alert\"\n >\n {keyError}\n </span>\n )}\n </form>\n </section>\n ) : (\n <section className=\"settings-popout__row settings-popout__row--stack\">\n <span className=\"settings-popout__inline\">\n <span className=\"settings-popout__label settings-popout__label--grow\">\n Access key\n </span>\n {/* Signed-in people only: the site refuses to save one for anyone else. */}\n {!resolving && userType !== 'public' && passkeysSupported() && (\n <button\n type=\"button\"\n className={`settings-popout__btn${needsPasskey ? ' settings-popout__btn--primary' : ''}`}\n disabled={passkeyMode !== null || passkeySaved}\n onClick={() => {\n savePasskey().catch(reportErr)\n }}\n >\n {passkeySaved\n ? 'Passkey saved'\n : passkeyMode === 'save'\n ? 'Saving…'\n : 'Save passkey'}\n </button>\n )}\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n disabled={passkeyMode === 'save'}\n onClick={openKeyForm}\n >\n Switch key\n </button>\n </span>\n {/* Its own line, so nothing moves under a double click. */}\n {passkeyMode === 'save' && (\n <span className=\"settings-popout__inline\">\n <button\n type=\"button\"\n className=\"settings-popout__btn\"\n onClick={() => cancelPasskey()}\n >\n Cancel\n </button>\n </span>\n )}\n {passkeyError && (\n <span className=\"settings-popout__error\" role=\"alert\">\n {passkeyError}\n </span>\n )}\n </section>\n )}\n\n {/* App-specific settings (optional) — same popout, one gear. */}\n {children && (\n <section className=\"settings-popout__row settings-popout__row--stack settings-popout__app\">\n {children}\n </section>\n )}\n </div>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -90,10 +90,15 @@ export type SignIn = (key: string) => Promise<SignInResult>;
|
|
|
90
90
|
*/
|
|
91
91
|
export interface SignInClient {
|
|
92
92
|
signIn: SignIn;
|
|
93
|
-
/** Like `signIn`, with a passkey in place of the key. Throws Error(message).
|
|
94
|
-
|
|
93
|
+
/** Like `signIn`, with a passkey in place of the key. Throws Error(message).
|
|
94
|
+
* `signal` cancels the open prompt. */
|
|
95
|
+
signInWithPasskey?: (opts?: {
|
|
96
|
+
signal?: AbortSignal;
|
|
97
|
+
}) => Promise<SignInResult>;
|
|
95
98
|
/** Saves a passkey for the signed-in person on this device. Throws Error(message). */
|
|
96
|
-
addPasskey?: (
|
|
99
|
+
addPasskey?: (opts?: {
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
}) => Promise<{
|
|
97
102
|
saved: boolean;
|
|
98
103
|
passkeys: number;
|
|
99
104
|
}>;
|
|
@@ -113,6 +118,8 @@ export interface SettingsSnapshot {
|
|
|
113
118
|
identity: Identity;
|
|
114
119
|
/** null = public caller (no pill) or the request failed. */
|
|
115
120
|
content: ContentLevelState | null;
|
|
121
|
+
/** Passkeys that sign this person in; null = unknown (older edge, host-supplied identity). */
|
|
122
|
+
passkeys: number | null;
|
|
116
123
|
}
|
|
117
124
|
/** Identity the host already knows, so we don't ask the server for it again. */
|
|
118
125
|
export interface SettingsPrefetchHints {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settingsClient.d.ts","sourceRoot":"","sources":["../../src/lib/settingsClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;
|
|
1
|
+
{"version":3,"file":"settingsClient.d.ts","sourceRoot":"","sources":["../../src/lib/settingsClient.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAuDH,MAAM,WAAW,iBAAiB;IAChC,oEAAoE;IACpE,KAAK,EAAE,MAAM,CAAA;IACb,uEAAuE;IACvE,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,4EAA4E;AAC5E,wBAAsB,eAAe,IAAI,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAYzE;AAED;uEACuE;AACvE,wBAAsB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAgBtF;AAED;gFACgF;AAChF,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAgBzE;AAED;;;;;;GAMG;AACH,MAAM,MAAM,IAAI,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,CAAA;AAErE,MAAM,WAAW,QAAQ;IACvB,QAAQ,EAAE,IAAI,CAAA;IACd,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;CACpB;AAyBD;;;;GAIG;AACH,wBAAsB,MAAM,IAAI,OAAO,CAAC,QAAQ,CAAC,CAShD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAKtC;AAED,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,OAAO,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;uEACuE;AACvE,MAAM,MAAM,MAAM,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAA;AAyB3D;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,MAAM,CAAA;IACd;4CACwC;IACxC,iBAAiB,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,KAAK,OAAO,CAAC,YAAY,CAAC,CAAA;IAC9E,sFAAsF;IACtF,UAAU,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE,WAAW,CAAA;KAAE,KAAK,OAAO,CAAC;QAAE,KAAK,EAAE,OAAO,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;CAChG;AAED,wBAAgB,gBAAgB,IAAI,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAuB/D;AAED,wBAAgB,UAAU,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAEnD;AAED,wEAAwE;AACxE,wBAAgB,iBAAiB,IAAI,OAAO,CAE3C;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,KAAK,GAAG,QAAQ,GAAG,MAAM,CAM1D;AAMD,kEAAkE;AAClE,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,QAAQ,CAAA;IAClB,4DAA4D;IAC5D,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACjC,8FAA8F;IAC9F,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CACxB;AAED,gFAAgF;AAChF,MAAM,WAAW,qBAAqB;IACpC,gEAAgE;IAChE,QAAQ,CAAC,EAAE,IAAI,CAAA;IACf,mEAAmE;IACnE,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAID;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,GAAE,qBAA0B,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAG7F"}
|
|
@@ -116,14 +116,21 @@ async function resolveSettings(hints) {
|
|
|
116
116
|
const selfResolving = hints.userType === void 0 && typeof window !== "undefined";
|
|
117
117
|
const bodyPromise = selfResolving ? sharedWhoami() : Promise.resolve(null);
|
|
118
118
|
const identityPromise = hints.userType !== void 0 ? Promise.resolve({ userType: hints.userType, name: hints.name ?? null }) : whoami();
|
|
119
|
-
const contentPromise = bodyPromise.then((
|
|
120
|
-
const carried = contentFromWhoami(
|
|
119
|
+
const contentPromise = bodyPromise.then((body2) => {
|
|
120
|
+
const carried = contentFromWhoami(body2);
|
|
121
121
|
if (carried) return carried;
|
|
122
122
|
if (looksSignedIn(hints.userType)) return getContentLevel();
|
|
123
123
|
return identityPromise.then((id) => id.userType === "public" ? null : getContentLevel());
|
|
124
124
|
});
|
|
125
|
-
const [identity, content] = await Promise.all([
|
|
126
|
-
|
|
125
|
+
const [identity, content, body] = await Promise.all([
|
|
126
|
+
identityPromise,
|
|
127
|
+
contentPromise,
|
|
128
|
+
bodyPromise
|
|
129
|
+
]);
|
|
130
|
+
return { identity, content, passkeys: passkeysFromWhoami(body) };
|
|
131
|
+
}
|
|
132
|
+
function passkeysFromWhoami(body) {
|
|
133
|
+
return body?.valid && typeof body.passkeys === "number" ? body.passkeys : null;
|
|
127
134
|
}
|
|
128
135
|
function contentFromWhoami(body) {
|
|
129
136
|
if (!body?.valid) return null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"settingsClient.js","sources":["../../src/lib/settingsClient.ts"],"sourcesContent":["/**\n * Client for the unified user-settings modal (ConnectedSettings).\n *\n * Two concerns, both cookie-authed against the hadoku.me edge-router, which\n * resolves the `hadoku_session` cookie / `X-Session-Id` header → X-User-Key +\n * X-User-Id for the backend:\n * - display name → POST /session/name (edge-router, self rename)\n * - content level → GET/PUT /prefs/api/v1/content-level (prefs-api)\n *\n * Switching keys goes through the SITE's sign-in client, never a call of ours:\n * see `loadSignIn()`, with `switchKeyHref()` as the fallback.\n *\n * These are same-origin relative paths, so ConnectedSettings is portable to any\n * app served under hadoku.me with zero wiring. Off-origin (e.g. a Storybook or\n * a non-hadoku host) every call resolves to null and the UI degrades to a\n * read-only shell — it never throws.\n *\n * The raw key is never held in the browser (the auth page deliberately drops\n * it), so name + content-level changes go through session identity, not a key\n * header, and no app ever asks for a key at all.\n *\n * WHEN THESE RUN\n * --------------\n * Everything the popout displays is resolved at PAGE LOAD, not on gear-click —\n * see `prefetchSettings()`. Opening settings must cost zero requests. Pinned by\n * `e2e/settings-prefetch.spec.ts` in hadoku-task.\n */\n\n/** Edge-router / prefs-api endpoints (same-origin relative). */\nconst CONTENT_LEVEL_PATH = '/prefs/api/v1/content-level'\nconst SESSION_NAME = '/session/name'\nconst SESSION_WHOAMI = '/session/whoami'\n\n/**\n * The ecosystem's shared boot-whoami promise. hadoku_site's `mf-loader.js`\n * kicks `GET /session/whoami` in parallel with the micro-frontend's module\n * import and parks the promise here precisely so the app it loads doesn't\n * resolve identity a second time; @wolffm/prefs-client already consumes it\n * under the same name. Reading it is what makes settings' identity free.\n *\n * Absent outside that shell (standalone vite, Capacitor, Storybook) — there we\n * fetch, and publish the result under the same key so the next reader is free\n * too.\n */\nconst WHOAMI_GLOBAL = '__hadokuWhoami'\n\n/**\n * Raw whoami body, as both mf-loader and this module see it off the wire.\n *\n * `contentLevel` / `maxContentLevel` are OPTIONAL because the shape is\n * versioned by whatever edge-router is deployed, not by this bundle. They\n * arrive from edge-router ≥ the 2026-08-07 change, which reports the level\n * `authGate` had already resolved to stamp X-Hadoku-Content-Level. When they\n * are absent we fall back to the standalone GET — see resolveSettings.\n */\ninterface WhoamiBody {\n valid?: boolean\n userType?: Tier\n name?: string | null\n contentLevel?: number\n maxContentLevel?: number\n}\n\n// Hard timeout on every settings request. Without it, a request throttled by\n// the browser (e.g. the tab going to the background) leaves the caller's\n// \"saving\" flag stuck true — which disabled the content-level pills until the\n// tab regained focus. AbortSignal.timeout guarantees the promise settles.\nconst REQUEST_TIMEOUT_MS = 8000\n\n/** Session-id header the same way whoami() sends it (cookie is the fallback). */\nfunction sessionHeaders(extra?: Record<string, string>): Record<string, string> {\n const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extra }\n const sid = typeof window !== 'undefined' ? localStorage.getItem('hadoku_session_id') : null\n if (sid) headers['X-Session-Id'] = sid\n return headers\n}\n\nexport interface ContentLevelState {\n /** Current global content-visibility level (1 = safest default). */\n level: number\n /** Highest level this caller's tier may select (friend=3, admin=4). */\n maxLevel: number\n}\n\n/** GET the caller's current content level + tier ceiling. null on error. */\nexport async function getContentLevel(): Promise<ContentLevelState | null> {\n try {\n const res = await fetch(CONTENT_LEVEL_PATH, {\n credentials: 'same-origin',\n headers: sessionHeaders(),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)\n })\n if (!res.ok) return null\n return (await res.json()) as ContentLevelState\n } catch {\n return null\n }\n}\n\n/** PUT a new content level. Server clamps to the tier ceiling → 400 if out of\n * range. Returns the stored state on success, null on any failure. */\nexport async function setContentLevel(level: number): Promise<ContentLevelState | null> {\n try {\n const res = await fetch(CONTENT_LEVEL_PATH, {\n method: 'PUT',\n credentials: 'same-origin',\n headers: sessionHeaders(),\n body: JSON.stringify({ level }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)\n })\n if (!res.ok) return null\n const stored = (await res.json()) as ContentLevelState\n patchSnapshot(prev => ({ ...prev, content: stored }))\n return stored\n } catch {\n return null\n }\n}\n\n/** POST a new display name for the signed-in user. Returns the stored name\n * (may differ from the request for wp-* protected keys) or null on failure. */\nexport async function setDisplayName(name: string): Promise<string | null> {\n try {\n const res = await fetch(SESSION_NAME, {\n method: 'POST',\n credentials: 'same-origin',\n headers: sessionHeaders(),\n body: JSON.stringify({ name }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)\n })\n if (!res.ok) return null\n const body = (await res.json()) as { ok: boolean; name: string | null }\n patchSnapshot(prev => ({ ...prev, identity: { ...prev.identity, name: body.name } }))\n return body.name\n } catch {\n return null\n }\n}\n\n/**\n * The hadoku tier ladder, LOW to HIGH: `public < friend < service < wife < admin`.\n * Mirrors TIER_RANK in @wolffm/worker-utils, which this browser bundle cannot\n * import (it would pull hono into the client). A tier missing here does not\n * error — it fails the union and, worse, drops out of every `Record<Tier, …>`\n * lookup, so the caller renders as if unrecognised. Keep it complete.\n */\nexport type Tier = 'public' | 'friend' | 'service' | 'wife' | 'admin'\n\nexport interface Identity {\n userType: Tier\n name: string | null\n}\n\n/**\n * The page's one whoami body, resolved at most once no matter how many callers.\n *\n * Under the hadoku.me shell this costs NO request: mf-loader kicks whoami in\n * parallel with the micro-frontend's module import and parks the promise on\n * WHOAMI_GLOBAL for exactly this reason (@wolffm/prefs-client reads the same\n * key). Elsewhere — standalone vite, Capacitor, Storybook — we fetch once and\n * publish under the same key so the next reader is free too.\n *\n * Resolves to null rather than throwing on any failure.\n */\nfunction sharedWhoami(): Promise<WhoamiBody | null> {\n const g = globalThis as { [WHOAMI_GLOBAL]?: Promise<WhoamiBody | null> }\n g[WHOAMI_GLOBAL] ??= fetch(SESSION_WHOAMI, {\n credentials: 'same-origin',\n headers: sessionHeaders(),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)\n })\n .then(res => (res.ok ? (res.json() as Promise<WhoamiBody>) : null))\n .catch(() => null)\n return g[WHOAMI_GLOBAL]\n}\n\n/**\n * Resolve the signed-in caller's tier + display name from the edge-router.\n * Returns { userType: 'public', name: null } for anonymous / off-origin / any\n * failure — never throws, so ConnectedSettings can render unconditionally.\n */\nexport async function whoami(): Promise<Identity> {\n if (typeof window === 'undefined') return { userType: 'public', name: null }\n try {\n const body = await sharedWhoami()\n if (!body?.valid) return { userType: 'public', name: null }\n return { userType: body.userType ?? 'public', name: body.name ?? null }\n } catch {\n return { userType: 'public', name: null }\n }\n}\n\n/**\n * Where a \"Switch key\" control sends the user when the inline sign-in client\n * (`loadSignIn()`) can't be loaded: the site's sign-in page, which brings them\n * back here afterwards.\n *\n * A browser session is created ONLY by /auth (hadoku_site, 2026-10-25): every\n * render of that page carries an edge-signed funnel token and the device\n * fingerprint, and /session/create alerts on — and will refuse — a sign-in\n * without them. So an app never posts a key itself.\n *\n * `switch=1` stops /auth from sending an already signed-in visitor straight\n * back, which would make switching impossible. `return` is this page's path,\n * query and hash; /auth redirects there with the new session cookie, and the\n * shell's mf-loader resyncs the localStorage mirror (hadoku_session_id /\n * hadoku_user_type) from /session/whoami on that load, so nothing here writes it.\n */\nexport function switchKeyHref(): string {\n return (\n '/auth?switch=1&return=' +\n encodeURIComponent(window.location.pathname + window.location.search + window.location.hash)\n )\n}\n\n/** What the site's sign-in client resolves to on success. */\nexport interface SignInResult {\n valid: boolean\n userType?: string\n sessionId?: string\n}\n\n/** `signIn` from hadoku.me's `/auth/sign-in.js`. Resolves on success; throws an\n * `Error` carrying the server's message on a refused or empty key. */\nexport type SignIn = (key: string) => Promise<SignInResult>\n\n/**\n * The site serves its sign-in page's own client as a module, so an app can sign\n * in INLINE on the same code path as /auth: a fresh funnel token per call, the\n * device fingerprint, and the `/session/create` post that sets the cookie (and\n * writes the hadoku_session_id / hadoku_user_type mirror). This package never\n * posts /session/create itself — a direct post would skip the fingerprint and\n * read as off-funnel — and it does not vendor a copy.\n *\n * The specifier is a VARIABLE on purpose. A literal `import('/auth/sign-in.js')`\n * would have every bundler that re-bundles this package (hadoku_site's build\n * among them) try to resolve the path at build time; this one is only ever\n * resolved by the browser, against whatever origin the page is on. For the\n * same reason it is typed here (`SignIn`) rather than by an ambient module\n * declaration, which only a literal specifier would pick up.\n *\n * The module starts collecting the fingerprint as soon as it evaluates, so call\n * this when a sign-in form OPENS, not when it submits. Memoised; resolves to\n * null when the module can't be loaded (a dev server that isn't hadoku.me),\n * which is the caller's cue to fall back to `switchKeyHref()`.\n */\nconst SIGN_IN_MODULE = '/auth/sign-in.js'\nlet signInModule: Promise<SignInClient | null> | null = null\n\n/**\n * The whole sign-in client. The passkey half is optional: a site build from\n * before it exports only `signIn`, and the passkey controls then fall back to\n * the /auth page (`passkeyHref()`), as a missing module does.\n */\nexport interface SignInClient {\n signIn: SignIn\n /** Like `signIn`, with a passkey in place of the key. Throws Error(message). */\n signInWithPasskey?: () => Promise<SignInResult>\n /** Saves a passkey for the signed-in person on this device. Throws Error(message). */\n addPasskey?: () => Promise<{ saved: boolean; passkeys: number }>\n}\n\nexport function loadSignInClient(): Promise<SignInClient | null> {\n signInModule ??= import(/* @vite-ignore */ SIGN_IN_MODULE).then(\n (m: Partial<Record<keyof SignInClient, unknown>>) =>\n typeof m.signIn === 'function'\n ? {\n signIn: m.signIn as SignIn,\n signInWithPasskey:\n typeof m.signInWithPasskey === 'function'\n ? (m.signInWithPasskey as SignInClient['signInWithPasskey'])\n : undefined,\n addPasskey:\n typeof m.addPasskey === 'function'\n ? (m.addPasskey as SignInClient['addPasskey'])\n : undefined\n }\n : null,\n () => {\n // Let a later open retry: a failed fetch is not cached by the browser.\n signInModule = null\n return null\n }\n )\n return signInModule\n}\n\nexport function loadSignIn(): Promise<SignIn | null> {\n return loadSignInClient().then(c => c?.signIn ?? null)\n}\n\n/** Whether this browser can use a passkey at all (WebAuthn present). */\nexport function passkeysSupported(): boolean {\n return typeof window !== 'undefined' && 'PublicKeyCredential' in window\n}\n\n/**\n * The /auth page's passkey flows, for when the sign-in client can't be\n * loaded: `add` saves one for the signed-in visitor, `signin` is the page with\n * its passkey button. Both come back here afterwards.\n */\nexport function passkeyHref(mode: 'add' | 'signin'): string {\n if (mode === 'signin') return switchKeyHref()\n return (\n '/auth?passkey=add&return=' +\n encodeURIComponent(window.location.pathname + window.location.search + window.location.hash)\n )\n}\n\n/* ------------------------------------------------------------------ *\n * Prefetch\n * ------------------------------------------------------------------ */\n\n/** Everything the settings popout displays, resolved together. */\nexport interface SettingsSnapshot {\n identity: Identity\n /** null = public caller (no pill) or the request failed. */\n content: ContentLevelState | null\n}\n\n/** Identity the host already knows, so we don't ask the server for it again. */\nexport interface SettingsPrefetchHints {\n /** Caller's tier. When supplied, whoami is skipped entirely. */\n userType?: Tier\n /** Caller's display name. Only meaningful alongside `userType`. */\n name?: string | null\n}\n\nlet snapshot: Promise<SettingsSnapshot> | null = null\n\n/**\n * Resolve — once per page load — everything the settings popout shows.\n *\n * This is THE entry point, and it is called from ConnectedSettings' MOUNT, not\n * from its open handler. Settings data is user identity: it is known at boot,\n * it doesn't change while the page is up, and making the gear the trigger left\n * the panel on screen but blank for a round trip (measured on prod: whoami\n * 167ms and content-level 186ms, issued concurrently). It now rides along with\n * the app's own boot traffic and the click costs nothing.\n *\n * Idempotent and shared: every caller after the first gets the same promise, so\n * N mounted consumers still produce at most one whoami for the whole page.\n * Mutations below write through to it rather than invalidating, so a remount\n * never shows a value the user just changed.\n *\n * Against a current edge-router this issues NO requests of its own at all — the\n * level rides on the whoami the shell already had in flight. See\n * resolveSettings for the fallback when it doesn't.\n */\nexport function prefetchSettings(hints: SettingsPrefetchHints = {}): Promise<SettingsSnapshot> {\n snapshot ??= resolveSettings(hints)\n return snapshot\n}\n\nasync function resolveSettings(hints: SettingsPrefetchHints): Promise<SettingsSnapshot> {\n // The whoami body, not just the adapted identity — edge-router carries the\n // content level on it (see below), and reading the raw body is how we get at\n // it without a second request.\n //\n // Skipped entirely when the host supplied identity: that prop exists for apps\n // that CANNOT reach /session/whoami (conjure sits behind a path-prefixed shim\n // where it 404s), so asking anyway would spend a request to learn nothing.\n const selfResolving = hints.userType === undefined && typeof window !== 'undefined'\n const bodyPromise = selfResolving ? sharedWhoami() : Promise.resolve(null)\n\n const identityPromise: Promise<Identity> =\n hints.userType !== undefined\n ? Promise.resolve({ userType: hints.userType, name: hints.name ?? null })\n : whoami()\n\n // PREFERRED PATH: take the level straight off whoami.\n //\n // edge-router's authGate has already resolved it (registry key → userId →\n // prefs D1) to stamp X-Hadoku-Content-Level, so reporting it on whoami costs\n // it nothing — and it saves us a proxied round trip to prefs-api that\n // measured 132-420ms against prod. Better still, whoami is in flight before\n // this bundle has even loaded, so the value is here the moment we ask.\n //\n // FALLBACK: the standalone GET, for any edge-router older than the\n // 2026-08-07 change, and for callers not behind it at all (Capacitor,\n // Storybook, a non-hadoku host). Gated on identity because the pill is\n // hidden for public callers — fetching an anonymous visitor's level is a\n // guaranteed-wasted request on every page view. `hadoku_user_type` is\n // written by the loader before anything mounts, so where it already says\n // \"signed in\" the fallback goes out concurrently rather than behind whoami.\n const contentPromise = bodyPromise.then(body => {\n const carried = contentFromWhoami(body)\n if (carried) return carried\n if (looksSignedIn(hints.userType)) return getContentLevel()\n return identityPromise.then(id => (id.userType === 'public' ? null : getContentLevel()))\n })\n\n const [identity, content] = await Promise.all([identityPromise, contentPromise])\n return { identity, content }\n}\n\n/**\n * Adapt the content level off a whoami body, or null if that edge-router\n * doesn't report it.\n *\n * Both fields are required, and both must be numbers: a body carrying a level\n * without its ceiling would render a pill with zero segments — worse than the\n * fallback fetch, because it looks like a working control that offers nothing.\n */\nfunction contentFromWhoami(body: WhoamiBody | null): ContentLevelState | null {\n if (!body?.valid) return null\n const { contentLevel, maxContentLevel } = body\n if (typeof contentLevel !== 'number' || typeof maxContentLevel !== 'number') return null\n if (maxContentLevel < 1) return null\n return { level: contentLevel, maxLevel: maxContentLevel }\n}\n\n/** Best-effort \"is this caller signed in?\" from state available synchronously. */\nfunction looksSignedIn(hinted?: Tier): boolean {\n if (hinted !== undefined) return hinted !== 'public'\n if (typeof window === 'undefined') return false\n const cached = localStorage.getItem('hadoku_user_type')\n return cached !== null && cached !== 'public'\n}\n\n/** Keep the prefetched snapshot in step with a successful mutation. */\nfunction patchSnapshot(patch: (prev: SettingsSnapshot) => SettingsSnapshot) {\n if (!snapshot) return\n snapshot = snapshot.then(patch)\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"settingsClient.js","sources":["../../src/lib/settingsClient.ts"],"sourcesContent":["/**\n * Client for the unified user-settings modal (ConnectedSettings).\n *\n * Two concerns, both cookie-authed against the hadoku.me edge-router, which\n * resolves the `hadoku_session` cookie / `X-Session-Id` header → X-User-Key +\n * X-User-Id for the backend:\n * - display name → POST /session/name (edge-router, self rename)\n * - content level → GET/PUT /prefs/api/v1/content-level (prefs-api)\n *\n * Switching keys goes through the SITE's sign-in client, never a call of ours:\n * see `loadSignIn()`, with `switchKeyHref()` as the fallback.\n *\n * These are same-origin relative paths, so ConnectedSettings is portable to any\n * app served under hadoku.me with zero wiring. Off-origin (e.g. a Storybook or\n * a non-hadoku host) every call resolves to null and the UI degrades to a\n * read-only shell — it never throws.\n *\n * The raw key is never held in the browser (the auth page deliberately drops\n * it), so name + content-level changes go through session identity, not a key\n * header, and no app ever asks for a key at all.\n *\n * WHEN THESE RUN\n * --------------\n * Everything the popout displays is resolved at PAGE LOAD, not on gear-click —\n * see `prefetchSettings()`. Opening settings must cost zero requests. Pinned by\n * `e2e/settings-prefetch.spec.ts` in hadoku-task.\n */\n\n/** Edge-router / prefs-api endpoints (same-origin relative). */\nconst CONTENT_LEVEL_PATH = '/prefs/api/v1/content-level'\nconst SESSION_NAME = '/session/name'\nconst SESSION_WHOAMI = '/session/whoami'\n\n/**\n * The ecosystem's shared boot-whoami promise. hadoku_site's `mf-loader.js`\n * kicks `GET /session/whoami` in parallel with the micro-frontend's module\n * import and parks the promise here precisely so the app it loads doesn't\n * resolve identity a second time; @wolffm/prefs-client already consumes it\n * under the same name. Reading it is what makes settings' identity free.\n *\n * Absent outside that shell (standalone vite, Capacitor, Storybook) — there we\n * fetch, and publish the result under the same key so the next reader is free\n * too.\n */\nconst WHOAMI_GLOBAL = '__hadokuWhoami'\n\n/**\n * Raw whoami body, as both mf-loader and this module see it off the wire.\n *\n * `contentLevel` / `maxContentLevel` are OPTIONAL because the shape is\n * versioned by whatever edge-router is deployed, not by this bundle. They\n * arrive from edge-router ≥ the 2026-08-07 change, which reports the level\n * `authGate` had already resolved to stamp X-Hadoku-Content-Level. When they\n * are absent we fall back to the standalone GET — see resolveSettings.\n *\n * `passkeys` (edge-router ≥ 2026-10-28) counts the passkeys that sign this\n * session in. Absent means unknown, never none.\n */\ninterface WhoamiBody {\n valid?: boolean\n userType?: Tier\n name?: string | null\n contentLevel?: number\n maxContentLevel?: number\n passkeys?: number\n}\n\n// Hard timeout on every settings request. Without it, a request throttled by\n// the browser (e.g. the tab going to the background) leaves the caller's\n// \"saving\" flag stuck true — which disabled the content-level pills until the\n// tab regained focus. AbortSignal.timeout guarantees the promise settles.\nconst REQUEST_TIMEOUT_MS = 8000\n\n/** Session-id header the same way whoami() sends it (cookie is the fallback). */\nfunction sessionHeaders(extra?: Record<string, string>): Record<string, string> {\n const headers: Record<string, string> = { 'Content-Type': 'application/json', ...extra }\n const sid = typeof window !== 'undefined' ? localStorage.getItem('hadoku_session_id') : null\n if (sid) headers['X-Session-Id'] = sid\n return headers\n}\n\nexport interface ContentLevelState {\n /** Current global content-visibility level (1 = safest default). */\n level: number\n /** Highest level this caller's tier may select (friend=3, admin=4). */\n maxLevel: number\n}\n\n/** GET the caller's current content level + tier ceiling. null on error. */\nexport async function getContentLevel(): Promise<ContentLevelState | null> {\n try {\n const res = await fetch(CONTENT_LEVEL_PATH, {\n credentials: 'same-origin',\n headers: sessionHeaders(),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)\n })\n if (!res.ok) return null\n return (await res.json()) as ContentLevelState\n } catch {\n return null\n }\n}\n\n/** PUT a new content level. Server clamps to the tier ceiling → 400 if out of\n * range. Returns the stored state on success, null on any failure. */\nexport async function setContentLevel(level: number): Promise<ContentLevelState | null> {\n try {\n const res = await fetch(CONTENT_LEVEL_PATH, {\n method: 'PUT',\n credentials: 'same-origin',\n headers: sessionHeaders(),\n body: JSON.stringify({ level }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)\n })\n if (!res.ok) return null\n const stored = (await res.json()) as ContentLevelState\n patchSnapshot(prev => ({ ...prev, content: stored }))\n return stored\n } catch {\n return null\n }\n}\n\n/** POST a new display name for the signed-in user. Returns the stored name\n * (may differ from the request for wp-* protected keys) or null on failure. */\nexport async function setDisplayName(name: string): Promise<string | null> {\n try {\n const res = await fetch(SESSION_NAME, {\n method: 'POST',\n credentials: 'same-origin',\n headers: sessionHeaders(),\n body: JSON.stringify({ name }),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)\n })\n if (!res.ok) return null\n const body = (await res.json()) as { ok: boolean; name: string | null }\n patchSnapshot(prev => ({ ...prev, identity: { ...prev.identity, name: body.name } }))\n return body.name\n } catch {\n return null\n }\n}\n\n/**\n * The hadoku tier ladder, LOW to HIGH: `public < friend < service < wife < admin`.\n * Mirrors TIER_RANK in @wolffm/worker-utils, which this browser bundle cannot\n * import (it would pull hono into the client). A tier missing here does not\n * error — it fails the union and, worse, drops out of every `Record<Tier, …>`\n * lookup, so the caller renders as if unrecognised. Keep it complete.\n */\nexport type Tier = 'public' | 'friend' | 'service' | 'wife' | 'admin'\n\nexport interface Identity {\n userType: Tier\n name: string | null\n}\n\n/**\n * The page's one whoami body, resolved at most once no matter how many callers.\n *\n * Under the hadoku.me shell this costs NO request: mf-loader kicks whoami in\n * parallel with the micro-frontend's module import and parks the promise on\n * WHOAMI_GLOBAL for exactly this reason (@wolffm/prefs-client reads the same\n * key). Elsewhere — standalone vite, Capacitor, Storybook — we fetch once and\n * publish under the same key so the next reader is free too.\n *\n * Resolves to null rather than throwing on any failure.\n */\nfunction sharedWhoami(): Promise<WhoamiBody | null> {\n const g = globalThis as { [WHOAMI_GLOBAL]?: Promise<WhoamiBody | null> }\n g[WHOAMI_GLOBAL] ??= fetch(SESSION_WHOAMI, {\n credentials: 'same-origin',\n headers: sessionHeaders(),\n signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)\n })\n .then(res => (res.ok ? (res.json() as Promise<WhoamiBody>) : null))\n .catch(() => null)\n return g[WHOAMI_GLOBAL]\n}\n\n/**\n * Resolve the signed-in caller's tier + display name from the edge-router.\n * Returns { userType: 'public', name: null } for anonymous / off-origin / any\n * failure — never throws, so ConnectedSettings can render unconditionally.\n */\nexport async function whoami(): Promise<Identity> {\n if (typeof window === 'undefined') return { userType: 'public', name: null }\n try {\n const body = await sharedWhoami()\n if (!body?.valid) return { userType: 'public', name: null }\n return { userType: body.userType ?? 'public', name: body.name ?? null }\n } catch {\n return { userType: 'public', name: null }\n }\n}\n\n/**\n * Where a \"Switch key\" control sends the user when the inline sign-in client\n * (`loadSignIn()`) can't be loaded: the site's sign-in page, which brings them\n * back here afterwards.\n *\n * A browser session is created ONLY by /auth (hadoku_site, 2026-10-25): every\n * render of that page carries an edge-signed funnel token and the device\n * fingerprint, and /session/create alerts on — and will refuse — a sign-in\n * without them. So an app never posts a key itself.\n *\n * `switch=1` stops /auth from sending an already signed-in visitor straight\n * back, which would make switching impossible. `return` is this page's path,\n * query and hash; /auth redirects there with the new session cookie, and the\n * shell's mf-loader resyncs the localStorage mirror (hadoku_session_id /\n * hadoku_user_type) from /session/whoami on that load, so nothing here writes it.\n */\nexport function switchKeyHref(): string {\n return (\n '/auth?switch=1&return=' +\n encodeURIComponent(window.location.pathname + window.location.search + window.location.hash)\n )\n}\n\n/** What the site's sign-in client resolves to on success. */\nexport interface SignInResult {\n valid: boolean\n userType?: string\n sessionId?: string\n}\n\n/** `signIn` from hadoku.me's `/auth/sign-in.js`. Resolves on success; throws an\n * `Error` carrying the server's message on a refused or empty key. */\nexport type SignIn = (key: string) => Promise<SignInResult>\n\n/**\n * The site serves its sign-in page's own client as a module, so an app can sign\n * in INLINE on the same code path as /auth: a fresh funnel token per call, the\n * device fingerprint, and the `/session/create` post that sets the cookie (and\n * writes the hadoku_session_id / hadoku_user_type mirror). This package never\n * posts /session/create itself — a direct post would skip the fingerprint and\n * read as off-funnel — and it does not vendor a copy.\n *\n * The specifier is a VARIABLE on purpose. A literal `import('/auth/sign-in.js')`\n * would have every bundler that re-bundles this package (hadoku_site's build\n * among them) try to resolve the path at build time; this one is only ever\n * resolved by the browser, against whatever origin the page is on. For the\n * same reason it is typed here (`SignIn`) rather than by an ambient module\n * declaration, which only a literal specifier would pick up.\n *\n * The module starts collecting the fingerprint as soon as it evaluates, so call\n * this when a sign-in form OPENS, not when it submits. Memoised; resolves to\n * null when the module can't be loaded (a dev server that isn't hadoku.me),\n * which is the caller's cue to fall back to `switchKeyHref()`.\n */\nconst SIGN_IN_MODULE = '/auth/sign-in.js'\nlet signInModule: Promise<SignInClient | null> | null = null\n\n/**\n * The whole sign-in client. The passkey half is optional: a site build from\n * before it exports only `signIn`, and the passkey controls then fall back to\n * the /auth page (`passkeyHref()`), as a missing module does.\n */\nexport interface SignInClient {\n signIn: SignIn\n /** Like `signIn`, with a passkey in place of the key. Throws Error(message).\n * `signal` cancels the open prompt. */\n signInWithPasskey?: (opts?: { signal?: AbortSignal }) => Promise<SignInResult>\n /** Saves a passkey for the signed-in person on this device. Throws Error(message). */\n addPasskey?: (opts?: { signal?: AbortSignal }) => Promise<{ saved: boolean; passkeys: number }>\n}\n\nexport function loadSignInClient(): Promise<SignInClient | null> {\n signInModule ??= import(/* @vite-ignore */ SIGN_IN_MODULE).then(\n (m: Partial<Record<keyof SignInClient, unknown>>) =>\n typeof m.signIn === 'function'\n ? {\n signIn: m.signIn as SignIn,\n signInWithPasskey:\n typeof m.signInWithPasskey === 'function'\n ? (m.signInWithPasskey as SignInClient['signInWithPasskey'])\n : undefined,\n addPasskey:\n typeof m.addPasskey === 'function'\n ? (m.addPasskey as SignInClient['addPasskey'])\n : undefined\n }\n : null,\n () => {\n // Let a later open retry: a failed fetch is not cached by the browser.\n signInModule = null\n return null\n }\n )\n return signInModule\n}\n\nexport function loadSignIn(): Promise<SignIn | null> {\n return loadSignInClient().then(c => c?.signIn ?? null)\n}\n\n/** Whether this browser can use a passkey at all (WebAuthn present). */\nexport function passkeysSupported(): boolean {\n return typeof window !== 'undefined' && 'PublicKeyCredential' in window\n}\n\n/**\n * The /auth page's passkey flows, for when the sign-in client can't be\n * loaded: `add` saves one for the signed-in visitor, `signin` is the page with\n * its passkey button. Both come back here afterwards.\n */\nexport function passkeyHref(mode: 'add' | 'signin'): string {\n if (mode === 'signin') return switchKeyHref()\n return (\n '/auth?passkey=add&return=' +\n encodeURIComponent(window.location.pathname + window.location.search + window.location.hash)\n )\n}\n\n/* ------------------------------------------------------------------ *\n * Prefetch\n * ------------------------------------------------------------------ */\n\n/** Everything the settings popout displays, resolved together. */\nexport interface SettingsSnapshot {\n identity: Identity\n /** null = public caller (no pill) or the request failed. */\n content: ContentLevelState | null\n /** Passkeys that sign this person in; null = unknown (older edge, host-supplied identity). */\n passkeys: number | null\n}\n\n/** Identity the host already knows, so we don't ask the server for it again. */\nexport interface SettingsPrefetchHints {\n /** Caller's tier. When supplied, whoami is skipped entirely. */\n userType?: Tier\n /** Caller's display name. Only meaningful alongside `userType`. */\n name?: string | null\n}\n\nlet snapshot: Promise<SettingsSnapshot> | null = null\n\n/**\n * Resolve — once per page load — everything the settings popout shows.\n *\n * This is THE entry point, and it is called from ConnectedSettings' MOUNT, not\n * from its open handler. Settings data is user identity: it is known at boot,\n * it doesn't change while the page is up, and making the gear the trigger left\n * the panel on screen but blank for a round trip (measured on prod: whoami\n * 167ms and content-level 186ms, issued concurrently). It now rides along with\n * the app's own boot traffic and the click costs nothing.\n *\n * Idempotent and shared: every caller after the first gets the same promise, so\n * N mounted consumers still produce at most one whoami for the whole page.\n * Mutations below write through to it rather than invalidating, so a remount\n * never shows a value the user just changed.\n *\n * Against a current edge-router this issues NO requests of its own at all — the\n * level rides on the whoami the shell already had in flight. See\n * resolveSettings for the fallback when it doesn't.\n */\nexport function prefetchSettings(hints: SettingsPrefetchHints = {}): Promise<SettingsSnapshot> {\n snapshot ??= resolveSettings(hints)\n return snapshot\n}\n\nasync function resolveSettings(hints: SettingsPrefetchHints): Promise<SettingsSnapshot> {\n // The whoami body, not just the adapted identity — edge-router carries the\n // content level on it (see below), and reading the raw body is how we get at\n // it without a second request.\n //\n // Skipped entirely when the host supplied identity: that prop exists for apps\n // that CANNOT reach /session/whoami (conjure sits behind a path-prefixed shim\n // where it 404s), so asking anyway would spend a request to learn nothing.\n const selfResolving = hints.userType === undefined && typeof window !== 'undefined'\n const bodyPromise = selfResolving ? sharedWhoami() : Promise.resolve(null)\n\n const identityPromise: Promise<Identity> =\n hints.userType !== undefined\n ? Promise.resolve({ userType: hints.userType, name: hints.name ?? null })\n : whoami()\n\n // PREFERRED PATH: take the level straight off whoami.\n //\n // edge-router's authGate has already resolved it (registry key → userId →\n // prefs D1) to stamp X-Hadoku-Content-Level, so reporting it on whoami costs\n // it nothing — and it saves us a proxied round trip to prefs-api that\n // measured 132-420ms against prod. Better still, whoami is in flight before\n // this bundle has even loaded, so the value is here the moment we ask.\n //\n // FALLBACK: the standalone GET, for any edge-router older than the\n // 2026-08-07 change, and for callers not behind it at all (Capacitor,\n // Storybook, a non-hadoku host). Gated on identity because the pill is\n // hidden for public callers — fetching an anonymous visitor's level is a\n // guaranteed-wasted request on every page view. `hadoku_user_type` is\n // written by the loader before anything mounts, so where it already says\n // \"signed in\" the fallback goes out concurrently rather than behind whoami.\n const contentPromise = bodyPromise.then(body => {\n const carried = contentFromWhoami(body)\n if (carried) return carried\n if (looksSignedIn(hints.userType)) return getContentLevel()\n return identityPromise.then(id => (id.userType === 'public' ? null : getContentLevel()))\n })\n\n const [identity, content, body] = await Promise.all([\n identityPromise,\n contentPromise,\n bodyPromise\n ])\n return { identity, content, passkeys: passkeysFromWhoami(body) }\n}\n\nfunction passkeysFromWhoami(body: WhoamiBody | null): number | null {\n return body?.valid && typeof body.passkeys === 'number' ? body.passkeys : null\n}\n\n/**\n * Adapt the content level off a whoami body, or null if that edge-router\n * doesn't report it.\n *\n * Both fields are required, and both must be numbers: a body carrying a level\n * without its ceiling would render a pill with zero segments — worse than the\n * fallback fetch, because it looks like a working control that offers nothing.\n */\nfunction contentFromWhoami(body: WhoamiBody | null): ContentLevelState | null {\n if (!body?.valid) return null\n const { contentLevel, maxContentLevel } = body\n if (typeof contentLevel !== 'number' || typeof maxContentLevel !== 'number') return null\n if (maxContentLevel < 1) return null\n return { level: contentLevel, maxLevel: maxContentLevel }\n}\n\n/** Best-effort \"is this caller signed in?\" from state available synchronously. */\nfunction looksSignedIn(hinted?: Tier): boolean {\n if (hinted !== undefined) return hinted !== 'public'\n if (typeof window === 'undefined') return false\n const cached = localStorage.getItem('hadoku_user_type')\n return cached !== null && cached !== 'public'\n}\n\n/** Keep the prefetched snapshot in step with a successful mutation. */\nfunction patchSnapshot(patch: (prev: SettingsSnapshot) => SettingsSnapshot) {\n if (!snapshot) return\n snapshot = snapshot.then(patch)\n}\n"],"names":["body"],"mappings":"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"}
|
package/dist/styles.css
CHANGED
|
@@ -724,6 +724,20 @@
|
|
|
724
724
|
position: relative;
|
|
725
725
|
display: inline-flex;
|
|
726
726
|
}
|
|
727
|
+
/* No passkey saved yet: a dot on the gear until one is. */
|
|
728
|
+
.settings-toggle-btn {
|
|
729
|
+
position: relative;
|
|
730
|
+
}
|
|
731
|
+
.settings-toggle-btn__dot {
|
|
732
|
+
position: absolute;
|
|
733
|
+
top: 2px;
|
|
734
|
+
right: 2px;
|
|
735
|
+
width: 8px;
|
|
736
|
+
height: 8px;
|
|
737
|
+
border-radius: 50%;
|
|
738
|
+
background: var(--color-primary);
|
|
739
|
+
pointer-events: none;
|
|
740
|
+
}
|
|
727
741
|
/* Full-viewport click-catcher, same trick ThemePicker uses. */
|
|
728
742
|
.settings-popout__overlay {
|
|
729
743
|
position: fixed;
|
|
@@ -787,6 +801,26 @@
|
|
|
787
801
|
font-weight: 500;
|
|
788
802
|
color: var(--color-text);
|
|
789
803
|
}
|
|
804
|
+
/* The display name is its own edit control: click it to rename. No preflight
|
|
805
|
+
* here, so the button's UA chrome is reset explicitly. */
|
|
806
|
+
.settings-popout__value--editable {
|
|
807
|
+
background: transparent;
|
|
808
|
+
border: 1px solid transparent;
|
|
809
|
+
border-radius: 7px;
|
|
810
|
+
padding: 4px 8px;
|
|
811
|
+
margin-right: -8px;
|
|
812
|
+
font-family: inherit;
|
|
813
|
+
cursor: text;
|
|
814
|
+
transition: border-color 0.15s ease;
|
|
815
|
+
}
|
|
816
|
+
.settings-popout__value--editable:hover,
|
|
817
|
+
.settings-popout__value--editable:focus-visible {
|
|
818
|
+
border-color: var(--color-border);
|
|
819
|
+
outline: none;
|
|
820
|
+
}
|
|
821
|
+
.settings-popout__label--grow {
|
|
822
|
+
margin-right: auto;
|
|
823
|
+
}
|
|
790
824
|
/* Label + control pair on the right of a row. `min-width: 0` is load-bearing:
|
|
791
825
|
* as a flex item of __row it otherwise floors at its own min-content width, and
|
|
792
826
|
* that includes the input's default `size=20` intrinsic width (195px in Chrome).
|
|
@@ -724,6 +724,20 @@
|
|
|
724
724
|
position: relative;
|
|
725
725
|
display: inline-flex;
|
|
726
726
|
}
|
|
727
|
+
/* No passkey saved yet: a dot on the gear until one is. */
|
|
728
|
+
.settings-toggle-btn {
|
|
729
|
+
position: relative;
|
|
730
|
+
}
|
|
731
|
+
.settings-toggle-btn__dot {
|
|
732
|
+
position: absolute;
|
|
733
|
+
top: 2px;
|
|
734
|
+
right: 2px;
|
|
735
|
+
width: 8px;
|
|
736
|
+
height: 8px;
|
|
737
|
+
border-radius: 50%;
|
|
738
|
+
background: var(--color-primary);
|
|
739
|
+
pointer-events: none;
|
|
740
|
+
}
|
|
727
741
|
/* Full-viewport click-catcher, same trick ThemePicker uses. */
|
|
728
742
|
.settings-popout__overlay {
|
|
729
743
|
position: fixed;
|
|
@@ -787,6 +801,26 @@
|
|
|
787
801
|
font-weight: 500;
|
|
788
802
|
color: var(--color-text);
|
|
789
803
|
}
|
|
804
|
+
/* The display name is its own edit control: click it to rename. No preflight
|
|
805
|
+
* here, so the button's UA chrome is reset explicitly. */
|
|
806
|
+
.settings-popout__value--editable {
|
|
807
|
+
background: transparent;
|
|
808
|
+
border: 1px solid transparent;
|
|
809
|
+
border-radius: 7px;
|
|
810
|
+
padding: 4px 8px;
|
|
811
|
+
margin-right: -8px;
|
|
812
|
+
font-family: inherit;
|
|
813
|
+
cursor: text;
|
|
814
|
+
transition: border-color 0.15s ease;
|
|
815
|
+
}
|
|
816
|
+
.settings-popout__value--editable:hover,
|
|
817
|
+
.settings-popout__value--editable:focus-visible {
|
|
818
|
+
border-color: var(--color-border);
|
|
819
|
+
outline: none;
|
|
820
|
+
}
|
|
821
|
+
.settings-popout__label--grow {
|
|
822
|
+
margin-right: auto;
|
|
823
|
+
}
|
|
790
824
|
/* Label + control pair on the right of a row. `min-width: 0` is load-bearing:
|
|
791
825
|
* as a flex item of __row it otherwise floors at its own min-content width, and
|
|
792
826
|
* that includes the input's default `size=20` intrinsic width (195px in Chrome).
|
package/package.json
CHANGED
package/src/app-header.css
CHANGED
|
@@ -77,6 +77,21 @@
|
|
|
77
77
|
display: inline-flex;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/* No passkey saved yet: a dot on the gear until one is. */
|
|
81
|
+
.settings-toggle-btn {
|
|
82
|
+
position: relative;
|
|
83
|
+
}
|
|
84
|
+
.settings-toggle-btn__dot {
|
|
85
|
+
position: absolute;
|
|
86
|
+
top: 2px;
|
|
87
|
+
right: 2px;
|
|
88
|
+
width: 8px;
|
|
89
|
+
height: 8px;
|
|
90
|
+
border-radius: 50%;
|
|
91
|
+
background: var(--color-primary);
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
}
|
|
94
|
+
|
|
80
95
|
/* Full-viewport click-catcher, same trick ThemePicker uses. */
|
|
81
96
|
.settings-popout__overlay {
|
|
82
97
|
position: fixed;
|
|
@@ -146,6 +161,26 @@
|
|
|
146
161
|
font-weight: 500;
|
|
147
162
|
color: var(--color-text);
|
|
148
163
|
}
|
|
164
|
+
/* The display name is its own edit control: click it to rename. No preflight
|
|
165
|
+
* here, so the button's UA chrome is reset explicitly. */
|
|
166
|
+
.settings-popout__value--editable {
|
|
167
|
+
background: transparent;
|
|
168
|
+
border: 1px solid transparent;
|
|
169
|
+
border-radius: 7px;
|
|
170
|
+
padding: 4px 8px;
|
|
171
|
+
margin-right: -8px;
|
|
172
|
+
font-family: inherit;
|
|
173
|
+
cursor: text;
|
|
174
|
+
transition: border-color 0.15s ease;
|
|
175
|
+
}
|
|
176
|
+
.settings-popout__value--editable:hover,
|
|
177
|
+
.settings-popout__value--editable:focus-visible {
|
|
178
|
+
border-color: var(--color-border);
|
|
179
|
+
outline: none;
|
|
180
|
+
}
|
|
181
|
+
.settings-popout__label--grow {
|
|
182
|
+
margin-right: auto;
|
|
183
|
+
}
|
|
149
184
|
|
|
150
185
|
/* Label + control pair on the right of a row. `min-width: 0` is load-bearing:
|
|
151
186
|
* as a flex item of __row it otherwise floors at its own min-content width, and
|
|
@@ -63,6 +63,19 @@ export interface ConnectedSettingsProps {
|
|
|
63
63
|
children?: React.ReactNode
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** Rejects once `signal` aborts, so a call that ignores the signal (an older
|
|
67
|
+
* site client, a prompt that never settles) still lets the UI go. */
|
|
68
|
+
function untilAborted(signal: AbortSignal): Promise<never> {
|
|
69
|
+
return new Promise((_, reject) => {
|
|
70
|
+
const fail = () => reject(new Error('cancelled'))
|
|
71
|
+
if (signal.aborted) fail()
|
|
72
|
+
else signal.addEventListener('abort', fail, { once: true })
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Abort reason when the popout closes: a dismissal, not an error to show. */
|
|
77
|
+
const CLOSED = 'closed'
|
|
78
|
+
|
|
66
79
|
/** Rejection sink for fire-and-forget settings actions. The client helpers
|
|
67
80
|
* already resolve to null on failure (state reflects it), so this only trips
|
|
68
81
|
* on truly unexpected throws — log rather than swallow silently. */
|
|
@@ -106,10 +119,17 @@ export function ConnectedSettings({
|
|
|
106
119
|
const [keyError, setKeyError] = useState<string | null>(null)
|
|
107
120
|
|
|
108
121
|
// Passkeys, through the same site client: save one while signed in, or
|
|
109
|
-
// sign in with one from the key form.
|
|
110
|
-
|
|
122
|
+
// sign in with one from the key form. One prompt at a time, and always
|
|
123
|
+
// cancellable: a password manager can dismiss the browser's prompt without
|
|
124
|
+
// ever answering it, so Cancel, closing the popout and the site client's own
|
|
125
|
+
// time limit each end the wait.
|
|
126
|
+
const [passkeyMode, setPasskeyMode] = useState<'save' | 'signin' | null>(null)
|
|
111
127
|
const [passkeySaved, setPasskeySaved] = useState(false)
|
|
128
|
+
// How many passkeys sign this person in, from whoami; null = unknown.
|
|
129
|
+
const [passkeyCount, setPasskeyCount] = useState<number | null>(null)
|
|
112
130
|
const [passkeyError, setPasskeyError] = useState<string | null>(null)
|
|
131
|
+
const passkeyCtl = useRef<AbortController | null>(null)
|
|
132
|
+
const cancelPasskey = useCallback((reason?: string) => passkeyCtl.current?.abort(reason), [])
|
|
113
133
|
|
|
114
134
|
// Content level
|
|
115
135
|
const [content, setContent] = useState<ContentLevelState | null>(null)
|
|
@@ -129,6 +149,15 @@ export function ConnectedSettings({
|
|
|
129
149
|
// There is no rank helper in this bundle, but "above public" needs none.
|
|
130
150
|
const showContentPill = userType !== 'public'
|
|
131
151
|
|
|
152
|
+
// Signed in on a key with no passkey: the gear carries a dot and Save passkey
|
|
153
|
+
// is the primary action until one is saved. Unknown counts never prompt.
|
|
154
|
+
const needsPasskey =
|
|
155
|
+
!resolving &&
|
|
156
|
+
userType !== 'public' &&
|
|
157
|
+
passkeyCount === 0 &&
|
|
158
|
+
!passkeySaved &&
|
|
159
|
+
passkeysSupported()
|
|
160
|
+
|
|
132
161
|
// Keep local identity in sync when the host controls it via props.
|
|
133
162
|
useEffect(() => {
|
|
134
163
|
if (userTypeProp !== undefined) setUserType(userTypeProp)
|
|
@@ -158,8 +187,9 @@ export function ConnectedSettings({
|
|
|
158
187
|
useEffect(() => {
|
|
159
188
|
let live = true
|
|
160
189
|
prefetchSettings({ userType: userTypeProp, name: nameProp })
|
|
161
|
-
.then(({ identity, content: state }) => {
|
|
190
|
+
.then(({ identity, content: state, passkeys }) => {
|
|
162
191
|
if (!live) return
|
|
192
|
+
setPasskeyCount(passkeys)
|
|
163
193
|
// Props stay authoritative: the host passed them because it can see an
|
|
164
194
|
// identity this component can't resolve for itself.
|
|
165
195
|
if (userTypeProp === undefined) {
|
|
@@ -246,40 +276,75 @@ export function ConnectedSettings({
|
|
|
246
276
|
}, [keyDraft])
|
|
247
277
|
|
|
248
278
|
const savePasskey = useCallback(async () => {
|
|
249
|
-
|
|
279
|
+
const ctl = new AbortController()
|
|
280
|
+
passkeyCtl.current = ctl
|
|
281
|
+
setPasskeyMode('save')
|
|
250
282
|
setPasskeyError(null)
|
|
251
|
-
const client = await loadSignInClient()
|
|
252
|
-
if (!client?.addPasskey) {
|
|
253
|
-
window.location.assign(passkeyHref('add'))
|
|
254
|
-
return
|
|
255
|
-
}
|
|
256
283
|
try {
|
|
257
|
-
await
|
|
284
|
+
const client = await Promise.race([loadSignInClient(), untilAborted(ctl.signal)])
|
|
285
|
+
if (!client?.addPasskey) {
|
|
286
|
+
window.location.assign(passkeyHref('add'))
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
await Promise.race([client.addPasskey({ signal: ctl.signal }), untilAborted(ctl.signal)])
|
|
258
290
|
setPasskeySaved(true)
|
|
259
291
|
} catch (err) {
|
|
260
|
-
|
|
292
|
+
if (ctl.signal.reason !== CLOSED) {
|
|
293
|
+
setPasskeyError(
|
|
294
|
+
ctl.signal.aborted ? 'Passkey not saved' : (err as Error)?.message || 'Passkey not saved'
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
} finally {
|
|
298
|
+
if (passkeyCtl.current === ctl) passkeyCtl.current = null
|
|
299
|
+
setPasskeyMode(null)
|
|
261
300
|
}
|
|
262
|
-
setPasskeyBusy(false)
|
|
263
301
|
}, [])
|
|
264
302
|
|
|
265
303
|
const signInWithPasskey = useCallback(async () => {
|
|
304
|
+
const ctl = new AbortController()
|
|
305
|
+
passkeyCtl.current = ctl
|
|
306
|
+
setPasskeyMode('signin')
|
|
266
307
|
setKeyBusy(true)
|
|
267
308
|
setKeyError(null)
|
|
268
|
-
|
|
269
|
-
if (!client?.signInWithPasskey) {
|
|
270
|
-
window.location.assign(passkeyHref('signin'))
|
|
271
|
-
return
|
|
272
|
-
}
|
|
309
|
+
let leaving = false
|
|
273
310
|
try {
|
|
274
|
-
await
|
|
311
|
+
const client = await Promise.race([loadSignInClient(), untilAborted(ctl.signal)])
|
|
312
|
+
if (!client?.signInWithPasskey) {
|
|
313
|
+
leaving = true
|
|
314
|
+
window.location.assign(passkeyHref('signin'))
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
await Promise.race([
|
|
318
|
+
client.signInWithPasskey({ signal: ctl.signal }),
|
|
319
|
+
untilAborted(ctl.signal)
|
|
320
|
+
])
|
|
321
|
+
// The page now acts for a different identity: everything must re-mount.
|
|
322
|
+
// Busy stays on through the reload so nothing can be resubmitted.
|
|
323
|
+
leaving = true
|
|
324
|
+
window.location.reload()
|
|
275
325
|
} catch (err) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
326
|
+
if (ctl.signal.reason !== CLOSED) {
|
|
327
|
+
setKeyError(
|
|
328
|
+
ctl.signal.aborted
|
|
329
|
+
? 'Passkey sign-in cancelled'
|
|
330
|
+
: (err as Error)?.message || 'Passkey sign-in cancelled'
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
} finally {
|
|
334
|
+
if (passkeyCtl.current === ctl) passkeyCtl.current = null
|
|
335
|
+
if (!leaving) {
|
|
336
|
+
setPasskeyMode(null)
|
|
337
|
+
setKeyBusy(false)
|
|
338
|
+
}
|
|
279
339
|
}
|
|
280
|
-
window.location.reload()
|
|
281
340
|
}, [])
|
|
282
341
|
|
|
342
|
+
// Closing the popout (or leaving the page) abandons an open prompt.
|
|
343
|
+
useEffect(() => {
|
|
344
|
+
if (!open) cancelPasskey(CLOSED)
|
|
345
|
+
}, [open, cancelPasskey])
|
|
346
|
+
useEffect(() => () => cancelPasskey(CLOSED), [cancelPasskey])
|
|
347
|
+
|
|
283
348
|
const pickLevel = useCallback(
|
|
284
349
|
async (level: number) => {
|
|
285
350
|
if (!content || level === content.level || levelSaving) return
|
|
@@ -302,11 +367,12 @@ export function ConnectedSettings({
|
|
|
302
367
|
<button
|
|
303
368
|
type="button"
|
|
304
369
|
className="app-header__icon-btn settings-toggle-btn"
|
|
305
|
-
aria-label=
|
|
370
|
+
aria-label={needsPasskey ? 'User settings, no passkey saved' : 'User settings'}
|
|
306
371
|
aria-expanded={open}
|
|
307
372
|
onClick={() => setOpen(v => !v)}
|
|
308
373
|
>
|
|
309
374
|
<SettingsIcon />
|
|
375
|
+
{needsPasskey && <span className="settings-toggle-btn__dot" aria-hidden="true" />}
|
|
310
376
|
</button>
|
|
311
377
|
|
|
312
378
|
{open && <div className="settings-popout__overlay" onClick={() => setOpen(false)} />}
|
|
@@ -349,6 +415,8 @@ export function ConnectedSettings({
|
|
|
349
415
|
saveName().catch(reportErr)
|
|
350
416
|
}
|
|
351
417
|
if (e.key === 'Escape') {
|
|
418
|
+
// The edit's Escape, not the popout's (window listener).
|
|
419
|
+
e.stopPropagation()
|
|
352
420
|
setEditingName(false)
|
|
353
421
|
setNameDraft(displayName ?? '')
|
|
354
422
|
}
|
|
@@ -366,19 +434,17 @@ export function ConnectedSettings({
|
|
|
366
434
|
</button>
|
|
367
435
|
</span>
|
|
368
436
|
) : (
|
|
369
|
-
<
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
</button>
|
|
381
|
-
</span>
|
|
437
|
+
<button
|
|
438
|
+
type="button"
|
|
439
|
+
className="settings-popout__value settings-popout__value--editable"
|
|
440
|
+
aria-label="Edit display name"
|
|
441
|
+
onClick={() => {
|
|
442
|
+
setNameDraft(displayName ?? '')
|
|
443
|
+
setEditingName(true)
|
|
444
|
+
}}
|
|
445
|
+
>
|
|
446
|
+
{displayName || 'Unnamed'}
|
|
447
|
+
</button>
|
|
382
448
|
)}
|
|
383
449
|
</section>
|
|
384
450
|
|
|
@@ -428,30 +494,6 @@ export function ConnectedSettings({
|
|
|
428
494
|
</section>
|
|
429
495
|
)}
|
|
430
496
|
|
|
431
|
-
{/* Save a passkey: signed-in people only (the site refuses others). */}
|
|
432
|
-
{!resolving && userType !== 'public' && passkeysSupported() && (
|
|
433
|
-
<>
|
|
434
|
-
<section className="settings-popout__row">
|
|
435
|
-
<span className="settings-popout__label">Passkey</span>
|
|
436
|
-
<button
|
|
437
|
-
type="button"
|
|
438
|
-
className="settings-popout__btn"
|
|
439
|
-
disabled={passkeyBusy || passkeySaved}
|
|
440
|
-
onClick={() => {
|
|
441
|
-
savePasskey().catch(reportErr)
|
|
442
|
-
}}
|
|
443
|
-
>
|
|
444
|
-
{passkeySaved ? 'Saved' : passkeyBusy ? 'Saving…' : 'Save'}
|
|
445
|
-
</button>
|
|
446
|
-
</section>
|
|
447
|
-
{passkeyError && (
|
|
448
|
-
<span className="settings-popout__error" role="alert">
|
|
449
|
-
{passkeyError}
|
|
450
|
-
</span>
|
|
451
|
-
)}
|
|
452
|
-
</>
|
|
453
|
-
)}
|
|
454
|
-
|
|
455
497
|
{/* Switch key. Signs in inline through the SITE's sign-in client
|
|
456
498
|
(the /auth page's own code path), never a post of ours. A real
|
|
457
499
|
<form> with a username + current-password pair, so password
|
|
@@ -501,16 +543,31 @@ export function ConnectedSettings({
|
|
|
501
543
|
</button>
|
|
502
544
|
</span>
|
|
503
545
|
{passkeysSupported() && (
|
|
504
|
-
<
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
546
|
+
<span className="settings-popout__inline">
|
|
547
|
+
<button
|
|
548
|
+
type="button"
|
|
549
|
+
className="settings-popout__btn"
|
|
550
|
+
disabled={keyBusy}
|
|
551
|
+
onClick={() => {
|
|
552
|
+
signInWithPasskey().catch(reportErr)
|
|
553
|
+
}}
|
|
554
|
+
>
|
|
555
|
+
{passkeyMode === 'signin' ? 'Waiting for passkey…' : 'Use a passkey'}
|
|
556
|
+
</button>
|
|
557
|
+
</span>
|
|
558
|
+
)}
|
|
559
|
+
{/* Cancel on a line of its own: in the row it would slide under
|
|
560
|
+
the pointer, and a double click would cancel what it started. */}
|
|
561
|
+
{passkeyMode === 'signin' && (
|
|
562
|
+
<span className="settings-popout__inline">
|
|
563
|
+
<button
|
|
564
|
+
type="button"
|
|
565
|
+
className="settings-popout__btn"
|
|
566
|
+
onClick={() => cancelPasskey()}
|
|
567
|
+
>
|
|
568
|
+
Cancel
|
|
569
|
+
</button>
|
|
570
|
+
</span>
|
|
514
571
|
)}
|
|
515
572
|
{keyError && (
|
|
516
573
|
<span
|
|
@@ -524,11 +581,54 @@ export function ConnectedSettings({
|
|
|
524
581
|
</form>
|
|
525
582
|
</section>
|
|
526
583
|
) : (
|
|
527
|
-
<section className="settings-popout__row">
|
|
528
|
-
<span className="settings-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
584
|
+
<section className="settings-popout__row settings-popout__row--stack">
|
|
585
|
+
<span className="settings-popout__inline">
|
|
586
|
+
<span className="settings-popout__label settings-popout__label--grow">
|
|
587
|
+
Access key
|
|
588
|
+
</span>
|
|
589
|
+
{/* Signed-in people only: the site refuses to save one for anyone else. */}
|
|
590
|
+
{!resolving && userType !== 'public' && passkeysSupported() && (
|
|
591
|
+
<button
|
|
592
|
+
type="button"
|
|
593
|
+
className={`settings-popout__btn${needsPasskey ? ' settings-popout__btn--primary' : ''}`}
|
|
594
|
+
disabled={passkeyMode !== null || passkeySaved}
|
|
595
|
+
onClick={() => {
|
|
596
|
+
savePasskey().catch(reportErr)
|
|
597
|
+
}}
|
|
598
|
+
>
|
|
599
|
+
{passkeySaved
|
|
600
|
+
? 'Passkey saved'
|
|
601
|
+
: passkeyMode === 'save'
|
|
602
|
+
? 'Saving…'
|
|
603
|
+
: 'Save passkey'}
|
|
604
|
+
</button>
|
|
605
|
+
)}
|
|
606
|
+
<button
|
|
607
|
+
type="button"
|
|
608
|
+
className="settings-popout__btn"
|
|
609
|
+
disabled={passkeyMode === 'save'}
|
|
610
|
+
onClick={openKeyForm}
|
|
611
|
+
>
|
|
612
|
+
Switch key
|
|
613
|
+
</button>
|
|
614
|
+
</span>
|
|
615
|
+
{/* Its own line, so nothing moves under a double click. */}
|
|
616
|
+
{passkeyMode === 'save' && (
|
|
617
|
+
<span className="settings-popout__inline">
|
|
618
|
+
<button
|
|
619
|
+
type="button"
|
|
620
|
+
className="settings-popout__btn"
|
|
621
|
+
onClick={() => cancelPasskey()}
|
|
622
|
+
>
|
|
623
|
+
Cancel
|
|
624
|
+
</button>
|
|
625
|
+
</span>
|
|
626
|
+
)}
|
|
627
|
+
{passkeyError && (
|
|
628
|
+
<span className="settings-popout__error" role="alert">
|
|
629
|
+
{passkeyError}
|
|
630
|
+
</span>
|
|
631
|
+
)}
|
|
532
632
|
</section>
|
|
533
633
|
)}
|
|
534
634
|
|
|
@@ -52,6 +52,9 @@ const WHOAMI_GLOBAL = '__hadokuWhoami'
|
|
|
52
52
|
* arrive from edge-router ≥ the 2026-08-07 change, which reports the level
|
|
53
53
|
* `authGate` had already resolved to stamp X-Hadoku-Content-Level. When they
|
|
54
54
|
* are absent we fall back to the standalone GET — see resolveSettings.
|
|
55
|
+
*
|
|
56
|
+
* `passkeys` (edge-router ≥ 2026-10-28) counts the passkeys that sign this
|
|
57
|
+
* session in. Absent means unknown, never none.
|
|
55
58
|
*/
|
|
56
59
|
interface WhoamiBody {
|
|
57
60
|
valid?: boolean
|
|
@@ -59,6 +62,7 @@ interface WhoamiBody {
|
|
|
59
62
|
name?: string | null
|
|
60
63
|
contentLevel?: number
|
|
61
64
|
maxContentLevel?: number
|
|
65
|
+
passkeys?: number
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
// Hard timeout on every settings request. Without it, a request throttled by
|
|
@@ -254,10 +258,11 @@ let signInModule: Promise<SignInClient | null> | null = null
|
|
|
254
258
|
*/
|
|
255
259
|
export interface SignInClient {
|
|
256
260
|
signIn: SignIn
|
|
257
|
-
/** Like `signIn`, with a passkey in place of the key. Throws Error(message).
|
|
258
|
-
|
|
261
|
+
/** Like `signIn`, with a passkey in place of the key. Throws Error(message).
|
|
262
|
+
* `signal` cancels the open prompt. */
|
|
263
|
+
signInWithPasskey?: (opts?: { signal?: AbortSignal }) => Promise<SignInResult>
|
|
259
264
|
/** Saves a passkey for the signed-in person on this device. Throws Error(message). */
|
|
260
|
-
addPasskey?: () => Promise<{ saved: boolean; passkeys: number }>
|
|
265
|
+
addPasskey?: (opts?: { signal?: AbortSignal }) => Promise<{ saved: boolean; passkeys: number }>
|
|
261
266
|
}
|
|
262
267
|
|
|
263
268
|
export function loadSignInClient(): Promise<SignInClient | null> {
|
|
@@ -316,6 +321,8 @@ export interface SettingsSnapshot {
|
|
|
316
321
|
identity: Identity
|
|
317
322
|
/** null = public caller (no pill) or the request failed. */
|
|
318
323
|
content: ContentLevelState | null
|
|
324
|
+
/** Passkeys that sign this person in; null = unknown (older edge, host-supplied identity). */
|
|
325
|
+
passkeys: number | null
|
|
319
326
|
}
|
|
320
327
|
|
|
321
328
|
/** Identity the host already knows, so we don't ask the server for it again. */
|
|
@@ -390,8 +397,16 @@ async function resolveSettings(hints: SettingsPrefetchHints): Promise<SettingsSn
|
|
|
390
397
|
return identityPromise.then(id => (id.userType === 'public' ? null : getContentLevel()))
|
|
391
398
|
})
|
|
392
399
|
|
|
393
|
-
const [identity, content] = await Promise.all([
|
|
394
|
-
|
|
400
|
+
const [identity, content, body] = await Promise.all([
|
|
401
|
+
identityPromise,
|
|
402
|
+
contentPromise,
|
|
403
|
+
bodyPromise
|
|
404
|
+
])
|
|
405
|
+
return { identity, content, passkeys: passkeysFromWhoami(body) }
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function passkeysFromWhoami(body: WhoamiBody | null): number | null {
|
|
409
|
+
return body?.valid && typeof body.passkeys === 'number' ? body.passkeys : null
|
|
395
410
|
}
|
|
396
411
|
|
|
397
412
|
/**
|