@mindful-web/marko-web-identity-x 1.87.1 → 1.89.0
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/CLAUDE.md +46 -3
- package/browser/form/fields/custom-boolean-yes-no.vue +99 -0
- package/browser/profile.vue +18 -6
- package/components/access.marko.js +2 -2
- package/components/comment-stream.marko.js +2 -2
- package/components/context.marko.js +2 -2
- package/components/form-access.marko.js +2 -2
- package/components/form-authenticate.marko.js +2 -2
- package/components/form-change-email.marko.js +2 -2
- package/components/form-login.marko.js +2 -2
- package/components/form-logout.marko.js +2 -2
- package/components/form-profile.marko.js +2 -2
- package/components/form-register.marko.js +2 -2
- package/components/google-init.marko.js +2 -2
- package/components/google-sign-in-button.marko.js +2 -2
- package/components/identify.marko.js +2 -2
- package/components/non-auth-identify.marko.js +2 -2
- package/components/progressive-profile-form.marko.js +2 -2
- package/components/subscribe.marko.js +2 -2
- package/package.json +2 -2
- package/routes/profile.js +12 -11
- package/test/utils/build-boolean-answers-input.spec.js +62 -0
- package/utils/build-boolean-answers-input.js +32 -0
package/CLAUDE.md
CHANGED
|
@@ -138,6 +138,49 @@ seam Omeda uses for rapid-identify + subscription sync. **Note:** `onLogout` cur
|
|
|
138
138
|
in the tokenless logout branch — a "fire `onLogout` always" change would let the `g_state` clear
|
|
139
139
|
become a hook rather than inline route code.
|
|
140
140
|
|
|
141
|
+
## Boolean questions have three states — added 2026-08
|
|
142
|
+
|
|
143
|
+
`AppUserCustomBooleanFieldAnswer.answer` is a **nullable** `Boolean`, and the schema says so:
|
|
144
|
+
*"A null value signifies a non answer. It's up to the implementing components to account for this."*
|
|
145
|
+
`hasAnswered` is literally `answer != null`. The three states are `true`, `false`, and *never
|
|
146
|
+
answered* — and on a subscription question the third is not a "no", it is silence.
|
|
147
|
+
|
|
148
|
+
The write side has no null: `UpdateAppUserCustomBooleanAnswer { fieldId: String!, value: Boolean! }`
|
|
149
|
+
on both identity-x and idx-compat. **Omission is the encoding for "no statement"**, and it is
|
|
150
|
+
load-bearing at every layer below this package: an absent entry in the `answers` array is a no-op,
|
|
151
|
+
`marko-web-omeda-identity-x/rapid-identify.js` skips `hasAnswered: false` answers *and* anything
|
|
152
|
+
outside `restrictAnswersToFieldIds`, and the Omeda resolver does `if (optedIn == null) return;`.
|
|
153
|
+
|
|
154
|
+
**The profile form used to break this in two places, and no longer does.**
|
|
155
|
+
|
|
156
|
+
- `browser/form/fields/custom-boolean-yes-no.vue` — the profile form's subscription control. Two
|
|
157
|
+
radios, and a `null` value selects **neither**, so unanswered is visually distinct from answered-
|
|
158
|
+
no. Emits the boolean, never null: a user can move between yes and no but cannot un-answer.
|
|
159
|
+
`required` is set on both radios and the browser validates them as a group — strictly better than
|
|
160
|
+
the checkbox it replaced, which could only ever be satisfied by opting *in*.
|
|
161
|
+
`browser/profile.vue`'s `onCustomBooleanChange` **assigns** the emitted answer; it used to
|
|
162
|
+
`!answer`, which on a `null` would read a "no" click as `true`.
|
|
163
|
+
- `utils/build-boolean-answers-input.js` — `routes/profile.js` used to map every rendered answer
|
|
164
|
+
with `value: Boolean(fieldAnswer.answer)` and push every field id into `submittedFieldIds`. A
|
|
165
|
+
member who opened /user/profile to fix a typo and never touched the subscription block therefore
|
|
166
|
+
sent `optedIn: false` / `Receive: 0` for every question they had never answered. The util drops
|
|
167
|
+
`answer == null` before both the mutation and `submittedFieldIds`, so the Omeda hook's
|
|
168
|
+
`restrictAnswersToFieldIds` drops them too. Spec:
|
|
169
|
+
`test/utils/build-boolean-answers-input.spec.js`.
|
|
170
|
+
|
|
171
|
+
**This is fleet-wide and unconditional** — no config flag; it arrives on a `mindful-web` dep bump.
|
|
172
|
+
|
|
173
|
+
**Scope is `/user/profile` only.** `custom-boolean.vue` (the checkbox) is untouched and still
|
|
174
|
+
renders in `custom-column.vue` (register/login required rows), `access.vue`/`download.vue` (gating),
|
|
175
|
+
and the theme's `idx-newsletter-form/*`. Those forms only ever create opt-ins on user creation, and
|
|
176
|
+
a signup box reads better as a checkbox.
|
|
177
|
+
|
|
178
|
+
**What this cannot fix.** For channel-backed booleans (`_kind: EMAIL|MAGAZINE`), idx-compat derives
|
|
179
|
+
`answer` from the member's stored `subscriptions[].active`
|
|
180
|
+
(`idx-compat/src/resolvers/app-user.ts:76-101`) — so once *any* subscription doc exists,
|
|
181
|
+
`active: false` is indistinguishable from an explicit no and renders with **No** pre-selected.
|
|
182
|
+
Channels have no unanswered state. This prevents new damage; it cannot un-answer history.
|
|
183
|
+
|
|
141
184
|
## Google Sign-In (One-Tap + button) — folded in 2026-07
|
|
142
185
|
|
|
143
186
|
Verifies a Google Identity Services (GIS) credential server-side and drops the user into the
|
|
@@ -348,7 +391,7 @@ configurations that are each a user-facing trap:
|
|
|
348
391
|
| Boot check | Why the runtime failure is worse than a failed deploy |
|
|
349
392
|
| --- | --- |
|
|
350
393
|
| `type: 'built-in'` | Unwritable ⇒ permanent gate loop. **Silent** — the only one of these with no visible symptom until a user is stuck. |
|
|
351
|
-
| `type: 'custom-boolean'` | The renderer bug that once made it unsatisfiable is fixed, but the gate forces every column `required: true`, and a *required* HTML5 checkbox can only ever be submitted checked — a progressive boolean could only ever record `true`, a toll rather than an answer.
|
|
394
|
+
| `type: 'custom-boolean'` | The renderer bug that once made it unsatisfiable is fixed, but the gate forces every column `required: true`, and a *required* HTML5 checkbox can only ever be submitted checked — a progressive boolean could only ever record `true`, a toll rather than an answer. The profile form now uses an explicit yes/no control that would satisfy this (see *Boolean questions have three states*), but it is not wired into `custom-column.vue` and the check still stands — re-allow only once the gate actually renders that control. |
|
|
352
395
|
| any other type | Falls through to `custom-column.vue`'s `<pre v-else>` debug block, printing raw field metadata to a real visitor. |
|
|
353
396
|
| id missing from a non-empty `activeCustomFieldIds` | `routes/profile.js:88-104` silently discards the answer ⇒ gate loop. |
|
|
354
397
|
| rows configured with no `apiToken` | Every write uses `getOrgUserApiToken()` and the form token is signed from it. |
|
|
@@ -439,8 +482,8 @@ wholesale: 18 of the first 48 production submitters on athleticbusiness had thei
|
|
|
439
482
|
subscription killed (`Receive: 0`, "Controlled Kills") at their exact submit second — the
|
|
440
483
|
2026-08-19 incident. The spec asserting the payload shape lives in
|
|
441
484
|
`marko-web-omeda-identity-x/test/rapid-identify.spec.js`. The profile route (`routes/profile.js`)
|
|
442
|
-
still passes booleans deliberately — its form renders the subscription
|
|
443
|
-
the
|
|
485
|
+
still passes booleans deliberately — its form renders the subscription questions — but it now only
|
|
486
|
+
passes the ones the member has actually answered; see *Boolean questions have three states*.
|
|
444
487
|
|
|
445
488
|
### Bot protection — verify, then discard the write rather than the visitor
|
|
446
489
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<form-group class-name="custom-boolean-yes-no">
|
|
3
|
+
<fieldset class="custom-boolean-yes-no__fieldset">
|
|
4
|
+
<legend class="custom-boolean-yes-no__legend">
|
|
5
|
+
<span v-html="message" />
|
|
6
|
+
<strong v-if="required" class="text-danger">*</strong>
|
|
7
|
+
</legend>
|
|
8
|
+
<div class="custom-boolean-yes-no__options">
|
|
9
|
+
<div
|
|
10
|
+
v-for="option in options"
|
|
11
|
+
:key="option.value"
|
|
12
|
+
class="custom-control custom-radio custom-control-inline"
|
|
13
|
+
>
|
|
14
|
+
<input
|
|
15
|
+
:id="`custom-boolean-${id}-${option.value}`"
|
|
16
|
+
:name="`custom-boolean-${id}`"
|
|
17
|
+
:checked="value === option.answer"
|
|
18
|
+
:disabled="disabled"
|
|
19
|
+
:required="required"
|
|
20
|
+
:value="option.value"
|
|
21
|
+
type="radio"
|
|
22
|
+
class="custom-control-input"
|
|
23
|
+
@change="$emit('input', option.answer)"
|
|
24
|
+
>
|
|
25
|
+
<label
|
|
26
|
+
class="custom-control-label"
|
|
27
|
+
:for="`custom-boolean-${id}-${option.value}`"
|
|
28
|
+
>{{ option.label }}</label>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</fieldset>
|
|
32
|
+
</form-group>
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<script>
|
|
36
|
+
import FormGroup from '../common/form-group.vue';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* An explicit yes/no answer for a boolean (subscription) question.
|
|
40
|
+
*
|
|
41
|
+
* Unlike `custom-boolean.vue` — the checkbox this replaces on the profile form — a `null` value
|
|
42
|
+
* selects NEITHER radio, so "never answered" is visually distinct from "answered no". The route
|
|
43
|
+
* then omits unanswered questions from the write rather than sending them as `false`, which is
|
|
44
|
+
* what keeps a profile save from unsubscribing someone who never touched the question.
|
|
45
|
+
*
|
|
46
|
+
* A user can move between yes and no but cannot return a question to unanswered; that is
|
|
47
|
+
* deliberate. `required` is set on both radios, which the browser validates as a group — strictly
|
|
48
|
+
* better than a required checkbox, which can only ever be satisfied by opting IN.
|
|
49
|
+
*
|
|
50
|
+
* The asterisk is inlined rather than reusing `form/common/form-label.vue`: the question here
|
|
51
|
+
* labels a radio GROUP, so it belongs in a `<legend>`, and that component always renders a
|
|
52
|
+
* `<label>` element.
|
|
53
|
+
*/
|
|
54
|
+
export default {
|
|
55
|
+
components: { FormGroup },
|
|
56
|
+
props: {
|
|
57
|
+
id: {
|
|
58
|
+
type: String,
|
|
59
|
+
required: true,
|
|
60
|
+
},
|
|
61
|
+
disabled: {
|
|
62
|
+
type: Boolean,
|
|
63
|
+
default: false,
|
|
64
|
+
},
|
|
65
|
+
/**
|
|
66
|
+
* `true`, `false`, or `null` when the question has never been answered.
|
|
67
|
+
* Deliberately untyped so `null` passes through without a Vue type warning.
|
|
68
|
+
*/
|
|
69
|
+
value: {
|
|
70
|
+
default: null,
|
|
71
|
+
validator: (v) => v === null || typeof v === 'boolean',
|
|
72
|
+
},
|
|
73
|
+
required: {
|
|
74
|
+
type: Boolean,
|
|
75
|
+
default: false,
|
|
76
|
+
},
|
|
77
|
+
message: {
|
|
78
|
+
type: String,
|
|
79
|
+
required: true,
|
|
80
|
+
},
|
|
81
|
+
yesLabel: {
|
|
82
|
+
type: String,
|
|
83
|
+
default: 'Yes',
|
|
84
|
+
},
|
|
85
|
+
noLabel: {
|
|
86
|
+
type: String,
|
|
87
|
+
default: 'No',
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
computed: {
|
|
91
|
+
options() {
|
|
92
|
+
return [
|
|
93
|
+
{ value: 'yes', answer: true, label: this.yesLabel },
|
|
94
|
+
{ value: 'no', answer: false, label: this.noLabel },
|
|
95
|
+
];
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
</script>
|
package/browser/profile.vue
CHANGED
|
@@ -173,12 +173,14 @@
|
|
|
173
173
|
:key="fieldAnswer.id"
|
|
174
174
|
class="col-12"
|
|
175
175
|
>
|
|
176
|
-
<custom-boolean
|
|
176
|
+
<custom-boolean-yes-no
|
|
177
177
|
:id="fieldAnswer.id"
|
|
178
178
|
:message="fieldAnswer.field.label"
|
|
179
179
|
:required="fieldAnswer.field.required"
|
|
180
180
|
:value="fieldAnswer.answer"
|
|
181
|
-
|
|
181
|
+
:yes-label="translate({ key: 'Yes' })"
|
|
182
|
+
:no-label="translate({ key: 'No' })"
|
|
183
|
+
@input="onCustomBooleanChange(fieldAnswer.id, $event)"
|
|
182
184
|
/>
|
|
183
185
|
</div>
|
|
184
186
|
</div>
|
|
@@ -264,7 +266,7 @@ import regionCountryCodes from './utils/region-country-codes';
|
|
|
264
266
|
|
|
265
267
|
import AddressBlock from './form/address-block.vue';
|
|
266
268
|
import FormConsent from './form/consent.vue';
|
|
267
|
-
import
|
|
269
|
+
import CustomBooleanYesNo from './form/fields/custom-boolean-yes-no.vue';
|
|
268
270
|
import CustomSelect from './form/fields/custom-select.vue';
|
|
269
271
|
import CustomText from './form/fields/custom-text.vue';
|
|
270
272
|
import GivenName from './form/fields/given-name.vue';
|
|
@@ -285,7 +287,7 @@ const { isArray } = Array;
|
|
|
285
287
|
export default {
|
|
286
288
|
components: {
|
|
287
289
|
AddressBlock,
|
|
288
|
-
|
|
290
|
+
CustomBooleanYesNo,
|
|
289
291
|
CustomSelect,
|
|
290
292
|
CustomText,
|
|
291
293
|
GivenName,
|
|
@@ -631,9 +633,13 @@ export default {
|
|
|
631
633
|
/**
|
|
632
634
|
*
|
|
633
635
|
*/
|
|
634
|
-
|
|
636
|
+
/**
|
|
637
|
+
* Assigns the answer the control emitted. Deliberately NOT a toggle of the stored value:
|
|
638
|
+
* the yes/no control has three input states and `!null` would read a "no" click as `true`.
|
|
639
|
+
*/
|
|
640
|
+
onCustomBooleanChange(id, answer) {
|
|
635
641
|
const objIndex = this.customBooleanFieldAnswers.findIndex(((obj) => obj.id === id));
|
|
636
|
-
|
|
642
|
+
if (objIndex === -1) return;
|
|
637
643
|
this.customBooleanFieldAnswers[objIndex].answer = answer;
|
|
638
644
|
|
|
639
645
|
this.user.customBooleanFieldAnswers = this.customBooleanFieldAnswers;
|
|
@@ -652,6 +658,8 @@ export default {
|
|
|
652
658
|
'To continue modifying your profile,': 'Para continuar editando seu perfil,',
|
|
653
659
|
'To return to the home page, ': 'Para voltar à página inicial, ',
|
|
654
660
|
'You must be logged in to modify your user profile.': 'Você precisa estar logado para modificar seu perfil de usuário.',
|
|
661
|
+
Yes: 'Sim',
|
|
662
|
+
No: 'Não',
|
|
655
663
|
},
|
|
656
664
|
es: {
|
|
657
665
|
'An error occurred': 'Se produjo un error:',
|
|
@@ -663,6 +671,8 @@ export default {
|
|
|
663
671
|
'To continue modifying your profile,': 'Para continuar modificando tu perfil, ',
|
|
664
672
|
'To return to the home page, ': 'Para volver a la página de inicio, ',
|
|
665
673
|
'You must be logged in to modify your user profile.': 'Você precisa estar logado para modificar seu perfil de usuário.',
|
|
674
|
+
Yes: 'Sí',
|
|
675
|
+
No: 'No',
|
|
666
676
|
},
|
|
667
677
|
en: {
|
|
668
678
|
'An error occurred: ': 'An error occurred: ',
|
|
@@ -674,6 +684,8 @@ export default {
|
|
|
674
684
|
'To continue modifying your profile,': 'To continue modifying your profile,',
|
|
675
685
|
'Your profile has been saved.': 'Your profile has been saved.',
|
|
676
686
|
'You must be logged in to modify your user profile.': 'You must be logged in to modify your user profile.',
|
|
687
|
+
Yes: 'Yes',
|
|
688
|
+
No: 'No',
|
|
677
689
|
},
|
|
678
690
|
};
|
|
679
691
|
const value = (i18n[lang] && i18n[lang][key]) ? i18n[lang][key] : i18n.en[key] || '';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/access.marko",
|
|
6
6
|
marko_component = require("./access.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -53,7 +53,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
53
53
|
}, marko_component);
|
|
54
54
|
|
|
55
55
|
marko_template.meta = {
|
|
56
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
56
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/access.marko",
|
|
57
57
|
component: "./access.marko",
|
|
58
58
|
tags: [
|
|
59
59
|
"@mindful-web/marko-core/components/resolve.marko"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/comment-stream.marko",
|
|
6
6
|
marko_component = require("./comment-stream.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -91,7 +91,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
91
91
|
}, marko_component);
|
|
92
92
|
|
|
93
93
|
marko_template.meta = {
|
|
94
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
94
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/comment-stream.marko",
|
|
95
95
|
component: "./comment-stream.marko",
|
|
96
96
|
tags: [
|
|
97
97
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/context.marko",
|
|
6
6
|
marko_component = require("./context.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
|
|
@@ -49,7 +49,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
49
49
|
}, marko_component);
|
|
50
50
|
|
|
51
51
|
marko_template.meta = {
|
|
52
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
52
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/context.marko",
|
|
53
53
|
component: "./context.marko",
|
|
54
54
|
tags: [
|
|
55
55
|
"@mindful-web/marko-core/components/resolve.marko"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-access.marko",
|
|
6
6
|
marko_component = require("./form-access.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
|
|
@@ -179,7 +179,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
179
179
|
}, marko_component);
|
|
180
180
|
|
|
181
181
|
marko_template.meta = {
|
|
182
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
182
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-access.marko",
|
|
183
183
|
component: "./form-access.marko",
|
|
184
184
|
tags: [
|
|
185
185
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-authenticate.marko",
|
|
6
6
|
marko_component = require("./form-authenticate.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -81,7 +81,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
81
81
|
}, marko_component);
|
|
82
82
|
|
|
83
83
|
marko_template.meta = {
|
|
84
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
84
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-authenticate.marko",
|
|
85
85
|
component: "./form-authenticate.marko",
|
|
86
86
|
tags: [
|
|
87
87
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-change-email.marko",
|
|
6
6
|
marko_component = require("./form-change-email.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -59,7 +59,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
59
59
|
}, marko_component);
|
|
60
60
|
|
|
61
61
|
marko_template.meta = {
|
|
62
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
62
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-change-email.marko",
|
|
63
63
|
component: "./form-change-email.marko",
|
|
64
64
|
tags: [
|
|
65
65
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-login.marko",
|
|
6
6
|
marko_component = require("./form-login.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -92,7 +92,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
92
92
|
}, marko_component);
|
|
93
93
|
|
|
94
94
|
marko_template.meta = {
|
|
95
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
95
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-login.marko",
|
|
96
96
|
component: "./form-login.marko",
|
|
97
97
|
tags: [
|
|
98
98
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-logout.marko",
|
|
6
6
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
7
|
module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
|
|
8
8
|
defaultValue = module_defaultValue.default || module_defaultValue,
|
|
@@ -29,7 +29,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
marko_template.meta = {
|
|
32
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
32
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-logout.marko",
|
|
33
33
|
tags: [
|
|
34
34
|
"@mindful-web/marko-web/components/browser-component.marko"
|
|
35
35
|
]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-profile.marko",
|
|
6
6
|
marko_component = require("./form-profile.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -84,7 +84,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
84
84
|
}, marko_component);
|
|
85
85
|
|
|
86
86
|
marko_template.meta = {
|
|
87
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
87
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-profile.marko",
|
|
88
88
|
component: "./form-profile.marko",
|
|
89
89
|
tags: [
|
|
90
90
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/form-register.marko",
|
|
6
6
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
7
|
marko_assign = require("marko/dist/runtime/helpers/assign"),
|
|
8
8
|
marko_web_identity_x_form_login_template = require("./form-login.marko"),
|
|
@@ -25,7 +25,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
marko_template.meta = {
|
|
28
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
28
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/form-register.marko",
|
|
29
29
|
tags: [
|
|
30
30
|
"./form-login.marko"
|
|
31
31
|
]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/google-init.marko",
|
|
6
6
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
7
|
marko_attr = require("marko/dist/runtime/html/helpers/attr"),
|
|
8
8
|
marko_str = require("marko/dist/runtime/helpers/to-string");
|
|
@@ -239,5 +239,5 @@ marko_template._ = marko_renderer(render, {
|
|
|
239
239
|
});
|
|
240
240
|
|
|
241
241
|
marko_template.meta = {
|
|
242
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
242
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/google-init.marko"
|
|
243
243
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/google-sign-in-button.marko",
|
|
6
6
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
7
7
|
helpers_escape_xml = require("marko/dist/runtime/html/helpers/escape-xml"),
|
|
8
8
|
marko_escapeXml = helpers_escape_xml.x,
|
|
@@ -40,5 +40,5 @@ marko_template._ = marko_renderer(render, {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
marko_template.meta = {
|
|
43
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
43
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/google-sign-in-button.marko"
|
|
44
44
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/identify.marko",
|
|
6
6
|
marko_component = require("./identify.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -50,7 +50,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
50
50
|
}, marko_component);
|
|
51
51
|
|
|
52
52
|
marko_template.meta = {
|
|
53
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
53
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/identify.marko",
|
|
54
54
|
component: "./identify.marko",
|
|
55
55
|
tags: [
|
|
56
56
|
"@mindful-web/marko-web-gtm/components/push.marko",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/non-auth-identify.marko",
|
|
6
6
|
marko_component = require("./non-auth-identify.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
|
|
@@ -45,7 +45,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
45
45
|
}, marko_component);
|
|
46
46
|
|
|
47
47
|
marko_template.meta = {
|
|
48
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
48
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/non-auth-identify.marko",
|
|
49
49
|
component: "./non-auth-identify.marko",
|
|
50
50
|
tags: [
|
|
51
51
|
"@mindful-web/marko-core/components/resolve.marko"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/progressive-profile-form.marko",
|
|
6
6
|
marko_component = require("./progressive-profile-form.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
|
|
@@ -137,7 +137,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
137
137
|
}, marko_component);
|
|
138
138
|
|
|
139
139
|
marko_template.meta = {
|
|
140
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
140
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/progressive-profile-form.marko",
|
|
141
141
|
component: "./progressive-profile-form.marko",
|
|
142
142
|
tags: [
|
|
143
143
|
"@mindful-web/marko-web/components/browser-component.marko",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"use strict";
|
|
3
3
|
|
|
4
4
|
var marko_template = module.exports = require("marko/dist/html").t(__filename),
|
|
5
|
-
marko_componentType = "/@mindful-web/marko-web-identity-x$1.
|
|
5
|
+
marko_componentType = "/@mindful-web/marko-web-identity-x$1.89.0/components/subscribe.marko",
|
|
6
6
|
marko_component = require("./subscribe.marko"),
|
|
7
7
|
marko_renderer = require("marko/dist/runtime/components/renderer"),
|
|
8
8
|
module_objectPath_module = require("@mindful-web/object-path"),
|
|
@@ -73,7 +73,7 @@ marko_template._ = marko_renderer(render, {
|
|
|
73
73
|
}, marko_component);
|
|
74
74
|
|
|
75
75
|
marko_template.meta = {
|
|
76
|
-
id: "/@mindful-web/marko-web-identity-x$1.
|
|
76
|
+
id: "/@mindful-web/marko-web-identity-x$1.89.0/components/subscribe.marko",
|
|
77
77
|
component: "./subscribe.marko",
|
|
78
78
|
tags: [
|
|
79
79
|
"@mindful-web/marko-web/components/browser-component.marko",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mindful-web/marko-web-identity-x",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.89.0",
|
|
4
4
|
"description": "Marko Wrapper for IdentityX",
|
|
5
5
|
"repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-identity-x",
|
|
6
6
|
"author": "Josh Worden <josh@parameter1.com>",
|
|
@@ -43,5 +43,5 @@
|
|
|
43
43
|
"chai": "^4.3.7",
|
|
44
44
|
"mocha": "^6.2.3"
|
|
45
45
|
},
|
|
46
|
-
"gitHead": "
|
|
46
|
+
"gitHead": "ef9a5dabc2cbaafb948233f37763a1bfd792b5a4"
|
|
47
47
|
}
|
package/routes/profile.js
CHANGED
|
@@ -3,6 +3,7 @@ const { asyncRoute } = require('@mindful-web/utils');
|
|
|
3
3
|
const { getAsArray, getAsObject } = require('@mindful-web/object-path');
|
|
4
4
|
const userFragment = require('../api/fragments/active-user');
|
|
5
5
|
const callHooksFor = require('../utils/call-hooks-for');
|
|
6
|
+
const buildBooleanAnswersInput = require('../utils/build-boolean-answers-input');
|
|
6
7
|
const overlaySubmittedBooleanAnswers = require('../utils/overlay-submitted-boolean-answers');
|
|
7
8
|
|
|
8
9
|
const mutation = gql`
|
|
@@ -125,18 +126,18 @@ module.exports = asyncRoute(async (req, res) => {
|
|
|
125
126
|
// guards against).
|
|
126
127
|
const submittedBooleanValues = new Map();
|
|
127
128
|
|
|
128
|
-
|
|
129
|
+
// An answer can be true, false or null. A null is a NON-answer and is DROPPED here rather than
|
|
130
|
+
// written as `false` — see build-boolean-answers-input for why that distinction is the whole
|
|
131
|
+
// difference between "the user said no" and "we unsubscribed someone who never answered".
|
|
132
|
+
// Unanswered ids never reach `submittedFieldIds` either, so the Omeda hook's
|
|
133
|
+
// `restrictAnswersToFieldIds` drops them from rapid-identify too.
|
|
134
|
+
const customBooleanFieldsInput = buildBooleanAnswersInput(
|
|
135
|
+
customBooleanFieldAnswers,
|
|
136
|
+
customFieldIds,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
if (customBooleanFieldsInput.length) {
|
|
129
140
|
// only update custom questions when there some :)
|
|
130
|
-
const customBooleanFieldsInput = customBooleanFieldAnswers.map((fieldAnswer) => ({
|
|
131
|
-
fieldId: fieldAnswer.field.id,
|
|
132
|
-
// can either be true, false or null. convert null to false.
|
|
133
|
-
// the form submit is effectively answers the question.
|
|
134
|
-
value: Boolean(fieldAnswer.answer),
|
|
135
|
-
})).filter(
|
|
136
|
-
customFieldIds.length > 0
|
|
137
|
-
? ({ fieldId }) => customFieldIds.includes(fieldId)
|
|
138
|
-
: () => true,
|
|
139
|
-
);
|
|
140
141
|
submittedFieldIds.push(...customBooleanFieldsInput.map(({ fieldId }) => fieldId));
|
|
141
142
|
customBooleanFieldsInput.forEach(({ fieldId, value }) => {
|
|
142
143
|
submittedBooleanValues.set(fieldId, value);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
const { describe, it } = require('mocha');
|
|
2
|
+
const { expect } = require('chai');
|
|
3
|
+
const build = require('../../utils/build-boolean-answers-input');
|
|
4
|
+
|
|
5
|
+
const answer = ({ id, value = null }) => ({
|
|
6
|
+
id: `ans-${id}`,
|
|
7
|
+
hasAnswered: value != null,
|
|
8
|
+
answer: value,
|
|
9
|
+
value,
|
|
10
|
+
field: { id, active: true },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('utils/build-boolean-answers-input', () => {
|
|
14
|
+
it('omits an unanswered question rather than writing it as false', () => {
|
|
15
|
+
// The whole point: this is what stops /user/profile from unsubscribing someone who never
|
|
16
|
+
// touched the subscription block.
|
|
17
|
+
expect(build([answer({ id: 'chan-mag' })])).to.deep.equal([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('preserves an explicit no as false', () => {
|
|
21
|
+
expect(build([answer({ id: 'chan-mag', value: false })]))
|
|
22
|
+
.to.deep.equal([{ fieldId: 'chan-mag', value: false }]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('preserves an explicit yes as true', () => {
|
|
26
|
+
expect(build([answer({ id: 'chan-mag', value: true })]))
|
|
27
|
+
.to.deep.equal([{ fieldId: 'chan-mag', value: true }]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps answered questions when mixed with unanswered ones', () => {
|
|
31
|
+
const answers = [
|
|
32
|
+
answer({ id: 'a', value: true }),
|
|
33
|
+
answer({ id: 'b' }),
|
|
34
|
+
answer({ id: 'c', value: false }),
|
|
35
|
+
];
|
|
36
|
+
expect(build(answers)).to.deep.equal([
|
|
37
|
+
{ fieldId: 'a', value: true },
|
|
38
|
+
{ fieldId: 'c', value: false },
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('applies the writable field id allowlist', () => {
|
|
43
|
+
const answers = [answer({ id: 'a', value: true }), answer({ id: 'b', value: false })];
|
|
44
|
+
expect(build(answers, ['b'])).to.deep.equal([{ fieldId: 'b', value: false }]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('writes every answered field when the allowlist is empty', () => {
|
|
48
|
+
const answers = [answer({ id: 'a', value: true }), answer({ id: 'b', value: false })];
|
|
49
|
+
expect(build(answers, [])).to.have.lengthOf(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('ignores answers carrying no field', () => {
|
|
53
|
+
expect(build([{ answer: true }, answer({ id: 'a', value: true })]))
|
|
54
|
+
.to.deep.equal([{ fieldId: 'a', value: true }]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns an empty array for missing or empty input', () => {
|
|
58
|
+
expect(build()).to.deep.equal([]);
|
|
59
|
+
expect(build(null)).to.deep.equal([]);
|
|
60
|
+
expect(build([])).to.deep.equal([]);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marshals submitted boolean field answers into `UpdateAppUserCustomBooleanAnswer` input.
|
|
3
|
+
*
|
|
4
|
+
* A boolean answer has THREE states, not two: `true`, `false`, and `null` (never answered —
|
|
5
|
+
* `hasAnswered` is `answer != null`). This drops the third rather than collapsing it to `false`.
|
|
6
|
+
*
|
|
7
|
+
* Why that matters: an unanswered question written as `false` is a *statement*. The profile route
|
|
8
|
+
* feeds its returned field ids to `onUserProfileUpdate` as `restrictAnswersToFieldIds`, and
|
|
9
|
+
* `marko-web-omeda-identity-x/rapid-identify.js` turns a deployment-type boolean into
|
|
10
|
+
* `optedIn: false` and a product boolean into `receive: false` (`Receive: 0`, a Controlled Kill).
|
|
11
|
+
* So a member who opened /user/profile to fix a typo and never touched the subscription block
|
|
12
|
+
* would unsubscribe themselves from every question they had never answered.
|
|
13
|
+
*
|
|
14
|
+
* Omission is the supported encoding for "no statement" at every layer below this one: the
|
|
15
|
+
* mutation input is `value: Boolean!` (there is no null to send), an absent entry in the `answers`
|
|
16
|
+
* array is a no-op, rapid-identify skips anything outside `restrictAnswersToFieldIds`, and the
|
|
17
|
+
* Omeda resolver does `if (optedIn == null) return;`.
|
|
18
|
+
*
|
|
19
|
+
* @param {object[]} answers The posted `customBooleanFieldAnswers`.
|
|
20
|
+
* @param {string[]} [customFieldIds] When non-empty, the allowlist of writable field ids.
|
|
21
|
+
* @returns {{ fieldId: string, value: boolean }[]} Answered, writable answers only.
|
|
22
|
+
*/
|
|
23
|
+
module.exports = (answers, customFieldIds = []) => (Array.isArray(answers) ? answers : [])
|
|
24
|
+
// A null answer is a NON-answer. Never coerce it to `false`.
|
|
25
|
+
.filter((fieldAnswer) => fieldAnswer && fieldAnswer.field && fieldAnswer.answer != null)
|
|
26
|
+
.map((fieldAnswer) => ({
|
|
27
|
+
fieldId: fieldAnswer.field.id,
|
|
28
|
+
value: Boolean(fieldAnswer.answer),
|
|
29
|
+
}))
|
|
30
|
+
.filter(customFieldIds.length > 0
|
|
31
|
+
? ({ fieldId }) => customFieldIds.includes(fieldId)
|
|
32
|
+
: () => true);
|