@things-factory/auth-ui 10.1.17 → 10.1.18
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-client/components/app-binding-detail.d.ts +46 -0
- package/dist-client/components/app-binding-detail.js +384 -0
- package/dist-client/components/app-binding-detail.js.map +1 -0
- package/dist-client/components/appliance-editor.d.ts +37 -0
- package/dist-client/components/appliance-editor.js +489 -0
- package/dist-client/components/appliance-editor.js.map +1 -0
- package/dist-client/components/application-editor.d.ts +31 -0
- package/dist-client/components/application-editor.js +491 -0
- package/dist-client/components/application-editor.js.map +1 -0
- package/dist-client/components/profile-component.js +28 -11
- package/dist-client/components/profile-component.js.map +1 -1
- package/dist-client/constants/application-type-parity.test.js +62 -0
- package/dist-client/constants/application-type-parity.test.js.map +1 -0
- package/dist-client/constants/application.d.ts +17 -7
- package/dist-client/constants/application.js +18 -8
- package/dist-client/constants/application.js.map +1 -1
- package/dist-client/pages/app-binding/app-bindings.d.ts +2 -0
- package/dist-client/pages/app-binding/app-bindings.js +19 -1
- package/dist-client/pages/app-binding/app-bindings.js.map +1 -1
- package/dist-client/pages/appliance/home.d.ts +2 -0
- package/dist-client/pages/appliance/home.js +21 -3
- package/dist-client/pages/appliance/home.js.map +1 -1
- package/dist-client/pages/application/applications.d.ts +2 -0
- package/dist-client/pages/application/applications.js +21 -3
- package/dist-client/pages/application/applications.js.map +1 -1
- package/dist-client/pages/secrets-are-not-printed.test.js +21 -3
- package/dist-client/pages/secrets-are-not-printed.test.js.map +1 -1
- package/dist-client/route.js +0 -15
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/form-values.d.ts +29 -0
- package/dist-client/utils/form-values.js +45 -0
- package/dist-client/utils/form-values.js.map +1 -1
- package/dist-client/utils/form-values.test.js +59 -1
- package/dist-client/utils/form-values.test.js.map +1 -1
- package/dist-client/utils/webauthn-verification.d.ts +56 -0
- package/dist-client/utils/webauthn-verification.js +79 -0
- package/dist-client/utils/webauthn-verification.js.map +1 -0
- package/dist-client/utils/webauthn-verification.test.js +79 -0
- package/dist-client/utils/webauthn-verification.test.js.map +1 -0
- package/package.json +3 -3
- package/things-factory.config.js +0 -5
- package/translations/en.json +25 -0
- package/translations/ja.json +26 -0
- package/translations/ko.json +25 -0
- package/translations/ms.json +26 -0
- package/translations/zh.json +26 -0
- package/dist-client/pages/app-binding/app-binding.d.ts +0 -2
- package/dist-client/pages/app-binding/app-binding.js +0 -371
- package/dist-client/pages/app-binding/app-binding.js.map +0 -1
- package/dist-client/pages/appliance/appliance.d.ts +0 -2
- package/dist-client/pages/appliance/appliance.js +0 -390
- package/dist-client/pages/appliance/appliance.js.map +0 -1
- package/dist-client/pages/appliance/register.js +0 -166
- package/dist-client/pages/appliance/register.js.map +0 -1
- package/dist-client/pages/application/application.d.ts +0 -2
- package/dist-client/pages/application/application.js +0 -384
- package/dist-client/pages/application/application.js.map +0 -1
- package/dist-client/pages/application/register.js +0 -246
- package/dist-client/pages/application/register.js.map +0 -1
- /package/dist-client/{pages/appliance/register.d.ts → constants/application-type-parity.test.d.ts} +0 -0
- /package/dist-client/{pages/application/register.d.ts → utils/webauthn-verification.test.d.ts} +0 -0
|
@@ -40,3 +40,32 @@ export declare function filledValues(formData: FormData): Record<string, FormVal
|
|
|
40
40
|
* watching a mutation fail on a rule the form never mentioned.
|
|
41
41
|
*/
|
|
42
42
|
export declare function missingRequired(formData: FormData, required: string[]): string[];
|
|
43
|
+
/**
|
|
44
|
+
* What the person changed, as a patch.
|
|
45
|
+
*
|
|
46
|
+
* ── Why this is not `filledValues` ──────────────────────────────────────────
|
|
47
|
+
* On a create, an empty box means "I did not fill this in" and the key is left out — that is
|
|
48
|
+
* what optional means on the wire, and it is what `filledValues` does.
|
|
49
|
+
*
|
|
50
|
+
* On an **update** that reading is wrong. `AppliancePatch` and `ApplicationPatch` are applied
|
|
51
|
+
* by spreading over the loaded row (`applianceRepository.save({ ...appliance, ...patch })`),
|
|
52
|
+
* so a key that is absent means "leave it alone" and a key set to `''` means "clear it".
|
|
53
|
+
* Those are two different intentions and a form has to tell them apart:
|
|
54
|
+
*
|
|
55
|
+
* netmask was '10.0.0.0/8', box is now empty → the person cleared it → send ''
|
|
56
|
+
* netmask was never set, box is still empty → nothing happened → send nothing
|
|
57
|
+
*
|
|
58
|
+
* `filledValues` cannot express the first one — it drops every empty box — so an update
|
|
59
|
+
* screen built on it can never clear a field. Sending the whole form instead has the opposite
|
|
60
|
+
* fault: every untouched box goes as `''`, which clears fields nobody touched, and for
|
|
61
|
+
* `ApplicationPatch.email` (a GraphQLEmailAddress) `''` is not "no email" but an invalid one,
|
|
62
|
+
* so the whole update is refused over a box the person never visited.
|
|
63
|
+
*
|
|
64
|
+
* Comparing against the record that was loaded is the only thing that tells the two apart.
|
|
65
|
+
*
|
|
66
|
+
* ── The other thing this buys ───────────────────────────────────────────────
|
|
67
|
+
* The patch carries only what moved. A screen that sends every field back writes values it
|
|
68
|
+
* merely displayed, so a row edited from a stale copy silently overwrites whatever someone
|
|
69
|
+
* else changed in between.
|
|
70
|
+
*/
|
|
71
|
+
export declare function changedValues(formData: FormData, original: Record<string, any> | null | undefined): Record<string, FormValue>;
|
|
@@ -54,4 +54,49 @@ export function missingRequired(formData, required) {
|
|
|
54
54
|
const values = filledValues(formData);
|
|
55
55
|
return required.filter(name => !values[name]);
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* What the person changed, as a patch.
|
|
59
|
+
*
|
|
60
|
+
* ── Why this is not `filledValues` ──────────────────────────────────────────
|
|
61
|
+
* On a create, an empty box means "I did not fill this in" and the key is left out — that is
|
|
62
|
+
* what optional means on the wire, and it is what `filledValues` does.
|
|
63
|
+
*
|
|
64
|
+
* On an **update** that reading is wrong. `AppliancePatch` and `ApplicationPatch` are applied
|
|
65
|
+
* by spreading over the loaded row (`applianceRepository.save({ ...appliance, ...patch })`),
|
|
66
|
+
* so a key that is absent means "leave it alone" and a key set to `''` means "clear it".
|
|
67
|
+
* Those are two different intentions and a form has to tell them apart:
|
|
68
|
+
*
|
|
69
|
+
* netmask was '10.0.0.0/8', box is now empty → the person cleared it → send ''
|
|
70
|
+
* netmask was never set, box is still empty → nothing happened → send nothing
|
|
71
|
+
*
|
|
72
|
+
* `filledValues` cannot express the first one — it drops every empty box — so an update
|
|
73
|
+
* screen built on it can never clear a field. Sending the whole form instead has the opposite
|
|
74
|
+
* fault: every untouched box goes as `''`, which clears fields nobody touched, and for
|
|
75
|
+
* `ApplicationPatch.email` (a GraphQLEmailAddress) `''` is not "no email" but an invalid one,
|
|
76
|
+
* so the whole update is refused over a box the person never visited.
|
|
77
|
+
*
|
|
78
|
+
* Comparing against the record that was loaded is the only thing that tells the two apart.
|
|
79
|
+
*
|
|
80
|
+
* ── The other thing this buys ───────────────────────────────────────────────
|
|
81
|
+
* The patch carries only what moved. A screen that sends every field back writes values it
|
|
82
|
+
* merely displayed, so a row edited from a stale copy silently overwrites whatever someone
|
|
83
|
+
* else changed in between.
|
|
84
|
+
*/
|
|
85
|
+
export function changedValues(formData, original) {
|
|
86
|
+
const record = original || {};
|
|
87
|
+
const patch = {};
|
|
88
|
+
for (const [key, value] of Array.from(formData.entries())) {
|
|
89
|
+
if (typeof value !== 'string') {
|
|
90
|
+
/* A file is never "unchanged" — there is nothing on the record to compare it against. */
|
|
91
|
+
patch[key] = value;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const next = value.trim();
|
|
95
|
+
const before = record[key] === null || record[key] === undefined ? '' : String(record[key]).trim();
|
|
96
|
+
if (next !== before) {
|
|
97
|
+
patch[key] = next;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return patch;
|
|
101
|
+
}
|
|
57
102
|
//# sourceMappingURL=form-values.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"form-values.js","sourceRoot":"","sources":["../../client/utils/form-values.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,QAAkB;IAC7C,MAAM,MAAM,GAA8B,EAAE,CAAA;IAE5C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC1D,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;YAE5B,IAAI,OAAO,EAAE,CAAC;gBACZ,MAAM,CAAC,GAAG,CAAC,GAAG,OAAO,CAAA;YACvB,CAAC;YAED,SAAQ;QACV,CAAC;QAED,MAAM,CAAC,GAAG,CAAC,GAAG,KAAa,CAAA;IAC7B,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,QAAkB,EAAE,QAAkB;IACpE,MAAM,MAAM,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAA;IAErC,OAAO,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;AAC/C,CAAC","sourcesContent":["/**\n * Turning a form into the object a mutation takes.\n *\n * ── The bug this exists for ──────────────────────────────────────────────────\n * The application register screen built its input like this:\n *\n * Array.from(formData.entries()).reduce((app, [key, value]) => { app[key] = value; … })\n *\n * `FormData` carries **every named input**, including the ones nobody typed in, each as an\n * empty string. So an untouched optional field went to the server as `''` rather than as\n * absent — and for `email` that is not the same thing at all:\n *\n * NewApplication.email is GraphQLEmailAddress, nullable\n * EmailAddressResolver.parseValue('') → \"Value is not a valid email address\"\n *\n * Registering an application without a contact email therefore failed, on a field the schema\n * marks optional. The screen said nothing useful about why.\n *\n * ── What \"absent\" means here ─────────────────────────────────────────────────\n * An empty text input means the person did not fill it in. It does not mean \"set this to the\n * empty string\" — HTML has no way to say that, and no field in these forms wants it. So an\n * empty value is dropped and the key never appears, which is what \"optional\" means on the\n * wire.\n *\n * Whitespace counts as empty for the same reason: a space typed by accident is not an answer,\n * and every scalar that rejects `''` rejects `' '` too.\n */\n\n/** Values a form can carry. Files pass through untouched — there is no empty file to drop. */\nexport type FormValue = string | File\n\n/**\n * The filled-in fields, as an object.\n *\n * Keys whose value is empty or whitespace are left out entirely rather than sent as `''`.\n */\nexport function filledValues(formData: FormData): Record<string, FormValue> {\n const values: Record<string, FormValue> = {}\n\n for (const [key, value] of Array.from(formData.entries())) {\n if (typeof value === 'string') {\n const trimmed = value.trim()\n\n if (trimmed) {\n values[key] = trimmed\n }\n\n continue\n }\n\n values[key] = value as File\n }\n\n return values\n}\n\n/**\n * Which of the named fields are still missing.\n *\n * The screen asks before it sends, so the person is told which box to fill rather than\n * watching a mutation fail on a rule the form never mentioned.\n */\nexport function missingRequired(formData: FormData, required: string[]): string[] {\n const values = filledValues(formData)\n\n return required.filter(name => !values[name])\n}\n"]}
|
|
1
|
+
{"version":3,"file":"form-values.js","sourceRoot":"","sources":["../../client/utils/form-values.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,QAAkB;IAC7C,MAAM,MAAM,GAA8B,EAAE,CAAA;IAE5C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC1D,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;YAE5B,IAAI,OAAO,EAAE,CAAC;gBACZ,MAAM,CAAC,GAAG,CAAC,GAAG,OAAO,CAAA;YACvB,CAAC;YAED,SAAQ;QACV,CAAC;QAED,MAAM,CAAC,GAAG,CAAC,GAAG,KAAa,CAAA;IAC7B,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,QAAkB,EAAE,QAAkB;IACpE,MAAM,MAAM,GAAG,YAAY,CAAC,QAAQ,CAAC,CAAA;IAErC,OAAO,QAAQ,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAA;AAC/C,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,MAAM,UAAU,aAAa,CAC3B,QAAkB,EAClB,QAAgD;IAEhD,MAAM,MAAM,GAAG,QAAQ,IAAI,EAAE,CAAA;IAC7B,MAAM,KAAK,GAA8B,EAAE,CAAA;IAE3C,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC1D,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;YAC9B,yFAAyF;YACzF,KAAK,CAAC,GAAG,CAAC,GAAG,KAAa,CAAA;YAC1B,SAAQ;QACV,CAAC;QAED,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;QACzB,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,IAAI,IAAI,MAAM,CAAC,GAAG,CAAC,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;QAElG,IAAI,IAAI,KAAK,MAAM,EAAE,CAAC;YACpB,KAAK,CAAC,GAAG,CAAC,GAAG,IAAI,CAAA;QACnB,CAAC;IACH,CAAC;IAED,OAAO,KAAK,CAAA;AACd,CAAC","sourcesContent":["/**\n * Turning a form into the object a mutation takes.\n *\n * ── The bug this exists for ──────────────────────────────────────────────────\n * The application register screen built its input like this:\n *\n * Array.from(formData.entries()).reduce((app, [key, value]) => { app[key] = value; … })\n *\n * `FormData` carries **every named input**, including the ones nobody typed in, each as an\n * empty string. So an untouched optional field went to the server as `''` rather than as\n * absent — and for `email` that is not the same thing at all:\n *\n * NewApplication.email is GraphQLEmailAddress, nullable\n * EmailAddressResolver.parseValue('') → \"Value is not a valid email address\"\n *\n * Registering an application without a contact email therefore failed, on a field the schema\n * marks optional. The screen said nothing useful about why.\n *\n * ── What \"absent\" means here ─────────────────────────────────────────────────\n * An empty text input means the person did not fill it in. It does not mean \"set this to the\n * empty string\" — HTML has no way to say that, and no field in these forms wants it. So an\n * empty value is dropped and the key never appears, which is what \"optional\" means on the\n * wire.\n *\n * Whitespace counts as empty for the same reason: a space typed by accident is not an answer,\n * and every scalar that rejects `''` rejects `' '` too.\n */\n\n/** Values a form can carry. Files pass through untouched — there is no empty file to drop. */\nexport type FormValue = string | File\n\n/**\n * The filled-in fields, as an object.\n *\n * Keys whose value is empty or whitespace are left out entirely rather than sent as `''`.\n */\nexport function filledValues(formData: FormData): Record<string, FormValue> {\n const values: Record<string, FormValue> = {}\n\n for (const [key, value] of Array.from(formData.entries())) {\n if (typeof value === 'string') {\n const trimmed = value.trim()\n\n if (trimmed) {\n values[key] = trimmed\n }\n\n continue\n }\n\n values[key] = value as File\n }\n\n return values\n}\n\n/**\n * Which of the named fields are still missing.\n *\n * The screen asks before it sends, so the person is told which box to fill rather than\n * watching a mutation fail on a rule the form never mentioned.\n */\nexport function missingRequired(formData: FormData, required: string[]): string[] {\n const values = filledValues(formData)\n\n return required.filter(name => !values[name])\n}\n\n/**\n * What the person changed, as a patch.\n *\n * ── Why this is not `filledValues` ──────────────────────────────────────────\n * On a create, an empty box means \"I did not fill this in\" and the key is left out — that is\n * what optional means on the wire, and it is what `filledValues` does.\n *\n * On an **update** that reading is wrong. `AppliancePatch` and `ApplicationPatch` are applied\n * by spreading over the loaded row (`applianceRepository.save({ ...appliance, ...patch })`),\n * so a key that is absent means \"leave it alone\" and a key set to `''` means \"clear it\".\n * Those are two different intentions and a form has to tell them apart:\n *\n * netmask was '10.0.0.0/8', box is now empty → the person cleared it → send ''\n * netmask was never set, box is still empty → nothing happened → send nothing\n *\n * `filledValues` cannot express the first one — it drops every empty box — so an update\n * screen built on it can never clear a field. Sending the whole form instead has the opposite\n * fault: every untouched box goes as `''`, which clears fields nobody touched, and for\n * `ApplicationPatch.email` (a GraphQLEmailAddress) `''` is not \"no email\" but an invalid one,\n * so the whole update is refused over a box the person never visited.\n *\n * Comparing against the record that was loaded is the only thing that tells the two apart.\n *\n * ── The other thing this buys ───────────────────────────────────────────────\n * The patch carries only what moved. A screen that sends every field back writes values it\n * merely displayed, so a row edited from a stale copy silently overwrites whatever someone\n * else changed in between.\n */\nexport function changedValues(\n formData: FormData,\n original: Record<string, any> | null | undefined\n): Record<string, FormValue> {\n const record = original || {}\n const patch: Record<string, FormValue> = {}\n\n for (const [key, value] of Array.from(formData.entries())) {\n if (typeof value !== 'string') {\n /* A file is never \"unchanged\" — there is nothing on the record to compare it against. */\n patch[key] = value as File\n continue\n }\n\n const next = value.trim()\n const before = record[key] === null || record[key] === undefined ? '' : String(record[key]).trim()\n\n if (next !== before) {\n patch[key] = next\n }\n }\n\n return patch\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { filledValues, missingRequired } from './form-values';
|
|
1
|
+
import { changedValues, filledValues, missingRequired } from './form-values';
|
|
2
2
|
/**
|
|
3
3
|
* The application register screen sent every input it had, including the untouched ones, each
|
|
4
4
|
* as an empty string. `NewApplication.email` is a GraphQLEmailAddress and rejects `''`, so
|
|
@@ -59,4 +59,62 @@ describe('what the form still needs', () => {
|
|
|
59
59
|
expect(missingRequired(form({ name: 'Billing' }), ['name'])).toEqual([]);
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
|
+
describe('what the person changed, as a patch', () => {
|
|
63
|
+
/*
|
|
64
|
+
* The patch is spread over the loaded row on the server, so an absent key means "leave it
|
|
65
|
+
* alone" and a key set to '' means "clear it". A form can only tell those apart by looking
|
|
66
|
+
* at what it loaded.
|
|
67
|
+
*/
|
|
68
|
+
it('sends only what moved', () => {
|
|
69
|
+
const patch = changedValues(form({ name: 'Gate B', brand: 'Acme', model: 'X1' }), {
|
|
70
|
+
name: 'Gate A',
|
|
71
|
+
brand: 'Acme',
|
|
72
|
+
model: 'X1'
|
|
73
|
+
});
|
|
74
|
+
expect(patch).toEqual({ name: 'Gate B' });
|
|
75
|
+
});
|
|
76
|
+
it('sends an empty string for a field the person cleared', () => {
|
|
77
|
+
/*
|
|
78
|
+
* ⚠ The case `filledValues` cannot express. Dropping this key would mean the netmask can
|
|
79
|
+
* never be removed once it is set — the screen would look like it saved and the value
|
|
80
|
+
* would come back on the next load.
|
|
81
|
+
*/
|
|
82
|
+
const patch = changedValues(form({ netmask: '' }), { netmask: '10.0.0.0/8' });
|
|
83
|
+
expect(patch).toEqual({ netmask: '' });
|
|
84
|
+
expect('netmask' in patch).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
it('leaves out a field that was empty and stayed empty', () => {
|
|
87
|
+
/*
|
|
88
|
+
* ⚠ The case sending the whole form gets wrong. `ApplicationPatch.email` is a
|
|
89
|
+
* GraphQLEmailAddress: '' is not "no email", it is an invalid one, and the server refuses
|
|
90
|
+
* the whole update over a box nobody visited.
|
|
91
|
+
*/
|
|
92
|
+
const patch = changedValues(form({ name: 'Billing', email: '' }), { name: 'Billing', email: null });
|
|
93
|
+
expect(patch).toEqual({});
|
|
94
|
+
expect('email' in patch).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
it('treats an absent field on the record as empty', () => {
|
|
97
|
+
expect(changedValues(form({ description: '' }), {})).toEqual({});
|
|
98
|
+
expect(changedValues(form({ description: 'hello' }), {})).toEqual({ description: 'hello' });
|
|
99
|
+
});
|
|
100
|
+
it('does not count retyping the same value with stray spaces as a change', () => {
|
|
101
|
+
expect(changedValues(form({ name: ' Billing ' }), { name: 'Billing' })).toEqual({});
|
|
102
|
+
});
|
|
103
|
+
it('compares a non-string field value as text', () => {
|
|
104
|
+
/* A record can carry a number or an enum; the box always holds text. */
|
|
105
|
+
expect(changedValues(form({ port: '8080' }), { port: 8080 })).toEqual({});
|
|
106
|
+
expect(changedValues(form({ port: '9090' }), { port: 8080 })).toEqual({ port: '9090' });
|
|
107
|
+
});
|
|
108
|
+
it('sends nothing at all when the person pressed save without touching anything', () => {
|
|
109
|
+
/*
|
|
110
|
+
* The whole point: an untouched form must not write. A screen that sends every field back
|
|
111
|
+
* overwrites whatever someone else changed while this copy was on screen.
|
|
112
|
+
*/
|
|
113
|
+
const record = { name: 'Gate A', description: 'front door', brand: 'Acme', netmask: null };
|
|
114
|
+
expect(changedValues(form({ name: 'Gate A', description: 'front door', brand: 'Acme', netmask: '' }), record)).toEqual({});
|
|
115
|
+
});
|
|
116
|
+
it('treats a missing record as everything being new', () => {
|
|
117
|
+
expect(changedValues(form({ name: 'Gate A', netmask: '' }), null)).toEqual({ name: 'Gate A' });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
62
120
|
//# sourceMappingURL=form-values.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"form-values.test.js","sourceRoot":"","sources":["../../client/utils/form-values.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { filledValues, missingRequired } from './form-values'\n\n/**\n * The application register screen sent every input it had, including the untouched ones, each\n * as an empty string. `NewApplication.email` is a GraphQLEmailAddress and rejects `''`, so\n * leaving the optional contact email blank made registration fail.\n */\n\nfunction form(entries: Record<string, string>): FormData {\n const data = new FormData()\n\n for (const [key, value] of Object.entries(entries)) {\n data.append(key, value)\n }\n\n return data\n}\n\ndescribe('the fields a person actually filled in', () => {\n it('keeps what was typed', () => {\n expect(filledValues(form({ name: 'Billing', url: 'https://example.com' }))).toEqual({\n name: 'Billing',\n url: 'https://example.com'\n })\n })\n\n it('leaves an untouched field out instead of sending an empty string', () => {\n /*\n * ⚠ The case this file exists for. `email: ''` is not \"no email\" to a scalar that\n * validates format — it is an invalid email, and the mutation fails on a field the\n * schema marks optional.\n */\n const values = filledValues(form({ name: 'Billing', email: '', url: '' }))\n\n expect(values).toEqual({ name: 'Billing' })\n expect('email' in values).toBe(false)\n })\n\n it('treats whitespace as untouched', () => {\n /* A space typed by accident is not an answer, and the scalars reject it too. */\n expect(filledValues(form({ name: 'Billing', email: ' ' }))).toEqual({ name: 'Billing' })\n })\n\n it('trims what it keeps', () => {\n expect(filledValues(form({ name: ' Billing ' }))).toEqual({ name: 'Billing' })\n })\n\n it('keeps a value that only looks empty', () => {\n /* '0' is falsy as a string in some hands. It is an answer. */\n expect(filledValues(form({ name: '0' }))).toEqual({ name: '0' })\n })\n})\n\ndescribe('what the form still needs', () => {\n it('names the required field nobody filled in', () => {\n expect(missingRequired(form({ name: '', description: 'x' }), ['name'])).toEqual(['name'])\n })\n\n it('counts whitespace as not filled in', () => {\n expect(missingRequired(form({ name: ' ' }), ['name'])).toEqual(['name'])\n })\n\n it('says nothing when the required fields are there', () => {\n expect(missingRequired(form({ name: 'Billing', email: '' }), ['name'])).toEqual([])\n })\n\n it('does not require what the schema left optional', () => {\n /*\n * The list comes from the schema — NewApplication marks only `name` non-null. Adding a\n * field here that the server accepts as absent would invent a rule the product does not\n * have.\n */\n expect(missingRequired(form({ name: 'Billing' }), ['name'])).toEqual([])\n })\n})\n"]}
|
|
1
|
+
{"version":3,"file":"form-values.test.js","sourceRoot":"","sources":["../../client/utils/form-values.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { changedValues, filledValues, missingRequired } from './form-values'\n\n/**\n * The application register screen sent every input it had, including the untouched ones, each\n * as an empty string. `NewApplication.email` is a GraphQLEmailAddress and rejects `''`, so\n * leaving the optional contact email blank made registration fail.\n */\n\nfunction form(entries: Record<string, string>): FormData {\n const data = new FormData()\n\n for (const [key, value] of Object.entries(entries)) {\n data.append(key, value)\n }\n\n return data\n}\n\ndescribe('the fields a person actually filled in', () => {\n it('keeps what was typed', () => {\n expect(filledValues(form({ name: 'Billing', url: 'https://example.com' }))).toEqual({\n name: 'Billing',\n url: 'https://example.com'\n })\n })\n\n it('leaves an untouched field out instead of sending an empty string', () => {\n /*\n * ⚠ The case this file exists for. `email: ''` is not \"no email\" to a scalar that\n * validates format — it is an invalid email, and the mutation fails on a field the\n * schema marks optional.\n */\n const values = filledValues(form({ name: 'Billing', email: '', url: '' }))\n\n expect(values).toEqual({ name: 'Billing' })\n expect('email' in values).toBe(false)\n })\n\n it('treats whitespace as untouched', () => {\n /* A space typed by accident is not an answer, and the scalars reject it too. */\n expect(filledValues(form({ name: 'Billing', email: ' ' }))).toEqual({ name: 'Billing' })\n })\n\n it('trims what it keeps', () => {\n expect(filledValues(form({ name: ' Billing ' }))).toEqual({ name: 'Billing' })\n })\n\n it('keeps a value that only looks empty', () => {\n /* '0' is falsy as a string in some hands. It is an answer. */\n expect(filledValues(form({ name: '0' }))).toEqual({ name: '0' })\n })\n})\n\ndescribe('what the form still needs', () => {\n it('names the required field nobody filled in', () => {\n expect(missingRequired(form({ name: '', description: 'x' }), ['name'])).toEqual(['name'])\n })\n\n it('counts whitespace as not filled in', () => {\n expect(missingRequired(form({ name: ' ' }), ['name'])).toEqual(['name'])\n })\n\n it('says nothing when the required fields are there', () => {\n expect(missingRequired(form({ name: 'Billing', email: '' }), ['name'])).toEqual([])\n })\n\n it('does not require what the schema left optional', () => {\n /*\n * The list comes from the schema — NewApplication marks only `name` non-null. Adding a\n * field here that the server accepts as absent would invent a rule the product does not\n * have.\n */\n expect(missingRequired(form({ name: 'Billing' }), ['name'])).toEqual([])\n })\n})\n\ndescribe('what the person changed, as a patch', () => {\n /*\n * The patch is spread over the loaded row on the server, so an absent key means \"leave it\n * alone\" and a key set to '' means \"clear it\". A form can only tell those apart by looking\n * at what it loaded.\n */\n\n it('sends only what moved', () => {\n const patch = changedValues(form({ name: 'Gate B', brand: 'Acme', model: 'X1' }), {\n name: 'Gate A',\n brand: 'Acme',\n model: 'X1'\n })\n\n expect(patch).toEqual({ name: 'Gate B' })\n })\n\n it('sends an empty string for a field the person cleared', () => {\n /*\n * ⚠ The case `filledValues` cannot express. Dropping this key would mean the netmask can\n * never be removed once it is set — the screen would look like it saved and the value\n * would come back on the next load.\n */\n const patch = changedValues(form({ netmask: '' }), { netmask: '10.0.0.0/8' })\n\n expect(patch).toEqual({ netmask: '' })\n expect('netmask' in patch).toBe(true)\n })\n\n it('leaves out a field that was empty and stayed empty', () => {\n /*\n * ⚠ The case sending the whole form gets wrong. `ApplicationPatch.email` is a\n * GraphQLEmailAddress: '' is not \"no email\", it is an invalid one, and the server refuses\n * the whole update over a box nobody visited.\n */\n const patch = changedValues(form({ name: 'Billing', email: '' }), { name: 'Billing', email: null })\n\n expect(patch).toEqual({})\n expect('email' in patch).toBe(false)\n })\n\n it('treats an absent field on the record as empty', () => {\n expect(changedValues(form({ description: '' }), {})).toEqual({})\n expect(changedValues(form({ description: 'hello' }), {})).toEqual({ description: 'hello' })\n })\n\n it('does not count retyping the same value with stray spaces as a change', () => {\n expect(changedValues(form({ name: ' Billing ' }), { name: 'Billing' })).toEqual({})\n })\n\n it('compares a non-string field value as text', () => {\n /* A record can carry a number or an enum; the box always holds text. */\n expect(changedValues(form({ port: '8080' }), { port: 8080 })).toEqual({})\n expect(changedValues(form({ port: '9090' }), { port: 8080 })).toEqual({ port: '9090' })\n })\n\n it('sends nothing at all when the person pressed save without touching anything', () => {\n /*\n * The whole point: an untouched form must not write. A screen that sends every field back\n * overwrites whatever someone else changed while this copy was on screen.\n */\n const record = { name: 'Gate A', description: 'front door', brand: 'Acme', netmask: null }\n\n expect(\n changedValues(form({ name: 'Gate A', description: 'front door', brand: 'Acme', netmask: '' }), record)\n ).toEqual({})\n })\n\n it('treats a missing record as everything being new', () => {\n expect(changedValues(form({ name: 'Gate A', netmask: '' }), null)).toEqual({ name: 'Gate A' })\n })\n})\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading what the server said about a passkey registration.
|
|
3
|
+
*
|
|
4
|
+
* ── Two defects that were hiding each other ─────────────────────────────────
|
|
5
|
+
* The profile screen posted the attestation and then did this:
|
|
6
|
+
*
|
|
7
|
+
* const verification = await fetch('/auth/verify-registration', { … }).then(res => res.json())
|
|
8
|
+
*
|
|
9
|
+
* if (verification.verified) { …registered… }
|
|
10
|
+
* else { console.error(await verification.text()); …refused… }
|
|
11
|
+
*
|
|
12
|
+
* `verification` is **already-parsed JSON**. It has no `.text()`. So the moment the refusal
|
|
13
|
+
* branch ran it threw a `TypeError`, the outer `catch` swallowed it, and the person was told
|
|
14
|
+
* the registration had *failed* — never that it had been *refused*.
|
|
15
|
+
*
|
|
16
|
+
* The refusal message was therefore unreachable — and behind it sat a second defect that the
|
|
17
|
+
* dead branch had been hiding. There were **two near-identical keys**:
|
|
18
|
+
*
|
|
19
|
+
* error.user credential regist_e_ration not allowed what the code asked for — no
|
|
20
|
+
* translation, in any of the five
|
|
21
|
+
* error.user credential registration not allowed translated in all five — no callers
|
|
22
|
+
*
|
|
23
|
+
* So even if the branch had run, the screen would have printed the key itself. Each defect
|
|
24
|
+
* kept the other out of sight: the dead branch meant the missing word never showed, and the
|
|
25
|
+
* missing word would have been the thing that made someone look. The code now asks for the
|
|
26
|
+
* spelling that has the words, and the misspelled entry is gone.
|
|
27
|
+
*
|
|
28
|
+
* ── Why this reads the body as text first ───────────────────────────────────
|
|
29
|
+
* `createWebAuthnMiddleware` does not answer `{ verified: false }` on a refusal — it **throws**
|
|
30
|
+
* `AuthError`, and what comes back is an error response whose body is not necessarily JSON.
|
|
31
|
+
* `res.json()` on it rejects, which is a second way into the same wrong branch.
|
|
32
|
+
*
|
|
33
|
+
* So the body is read **once, as text**, and parsed only if it happens to be JSON. Text always
|
|
34
|
+
* reads; that is the point. Whatever the server said survives into `reason` either way, which
|
|
35
|
+
* is what was missing from the log.
|
|
36
|
+
*/
|
|
37
|
+
export type VerificationOutcome =
|
|
38
|
+
/** The server confirmed it. */
|
|
39
|
+
{
|
|
40
|
+
outcome: 'registered';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The server said no. `reason` is the server's own words, for the log — never for the
|
|
44
|
+
* screen: it is an error code or a passport message, in English, written for us.
|
|
45
|
+
*/
|
|
46
|
+
| {
|
|
47
|
+
outcome: 'refused';
|
|
48
|
+
reason: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* What the verify-registration response means.
|
|
52
|
+
*
|
|
53
|
+
* Never throws. A registration attempt has exactly two ends a person cares about, and an
|
|
54
|
+
* exception thrown while working out which one it was is how this screen lost the difference.
|
|
55
|
+
*/
|
|
56
|
+
export declare function readVerification(response: Response): Promise<VerificationOutcome>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading what the server said about a passkey registration.
|
|
3
|
+
*
|
|
4
|
+
* ── Two defects that were hiding each other ─────────────────────────────────
|
|
5
|
+
* The profile screen posted the attestation and then did this:
|
|
6
|
+
*
|
|
7
|
+
* const verification = await fetch('/auth/verify-registration', { … }).then(res => res.json())
|
|
8
|
+
*
|
|
9
|
+
* if (verification.verified) { …registered… }
|
|
10
|
+
* else { console.error(await verification.text()); …refused… }
|
|
11
|
+
*
|
|
12
|
+
* `verification` is **already-parsed JSON**. It has no `.text()`. So the moment the refusal
|
|
13
|
+
* branch ran it threw a `TypeError`, the outer `catch` swallowed it, and the person was told
|
|
14
|
+
* the registration had *failed* — never that it had been *refused*.
|
|
15
|
+
*
|
|
16
|
+
* The refusal message was therefore unreachable — and behind it sat a second defect that the
|
|
17
|
+
* dead branch had been hiding. There were **two near-identical keys**:
|
|
18
|
+
*
|
|
19
|
+
* error.user credential regist_e_ration not allowed what the code asked for — no
|
|
20
|
+
* translation, in any of the five
|
|
21
|
+
* error.user credential registration not allowed translated in all five — no callers
|
|
22
|
+
*
|
|
23
|
+
* So even if the branch had run, the screen would have printed the key itself. Each defect
|
|
24
|
+
* kept the other out of sight: the dead branch meant the missing word never showed, and the
|
|
25
|
+
* missing word would have been the thing that made someone look. The code now asks for the
|
|
26
|
+
* spelling that has the words, and the misspelled entry is gone.
|
|
27
|
+
*
|
|
28
|
+
* ── Why this reads the body as text first ───────────────────────────────────
|
|
29
|
+
* `createWebAuthnMiddleware` does not answer `{ verified: false }` on a refusal — it **throws**
|
|
30
|
+
* `AuthError`, and what comes back is an error response whose body is not necessarily JSON.
|
|
31
|
+
* `res.json()` on it rejects, which is a second way into the same wrong branch.
|
|
32
|
+
*
|
|
33
|
+
* So the body is read **once, as text**, and parsed only if it happens to be JSON. Text always
|
|
34
|
+
* reads; that is the point. Whatever the server said survives into `reason` either way, which
|
|
35
|
+
* is what was missing from the log.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* What the verify-registration response means.
|
|
39
|
+
*
|
|
40
|
+
* Never throws. A registration attempt has exactly two ends a person cares about, and an
|
|
41
|
+
* exception thrown while working out which one it was is how this screen lost the difference.
|
|
42
|
+
*/
|
|
43
|
+
export async function readVerification(response) {
|
|
44
|
+
const body = await readBody(response);
|
|
45
|
+
const parsed = asJson(body);
|
|
46
|
+
if (response.ok && parsed?.verified === true) {
|
|
47
|
+
return { outcome: 'registered' };
|
|
48
|
+
}
|
|
49
|
+
return { outcome: 'refused', reason: reasonFrom(response, parsed, body) };
|
|
50
|
+
}
|
|
51
|
+
/** The body, as text. A body that cannot be read at all is not a reason to lose the outcome. */
|
|
52
|
+
async function readBody(response) {
|
|
53
|
+
try {
|
|
54
|
+
return await response.text();
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function asJson(body) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(body);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The most specific thing the server said.
|
|
70
|
+
*
|
|
71
|
+
* `AuthError` carries `errorCode`; passport puts its own wording in `message`. Falling back to
|
|
72
|
+
* the raw body keeps a plain-text error intact, and to the status when the body is empty —
|
|
73
|
+
* because "refused, and it said nothing" is itself worth knowing in a log.
|
|
74
|
+
*/
|
|
75
|
+
function reasonFrom(response, parsed, body) {
|
|
76
|
+
const said = parsed?.errorCode || parsed?.message || parsed?.error || body.trim();
|
|
77
|
+
return said || `HTTP ${response.status}`;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=webauthn-verification.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webauthn-verification.js","sourceRoot":"","sources":["../../client/utils/webauthn-verification.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAWH;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,QAAkB;IACvD,MAAM,IAAI,GAAG,MAAM,QAAQ,CAAC,QAAQ,CAAC,CAAA;IACrC,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,CAAA;IAE3B,IAAI,QAAQ,CAAC,EAAE,IAAI,MAAM,EAAE,QAAQ,KAAK,IAAI,EAAE,CAAC;QAC7C,OAAO,EAAE,OAAO,EAAE,YAAY,EAAE,CAAA;IAClC,CAAC;IAED,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,CAAA;AAC3E,CAAC;AAED,gGAAgG;AAChG,KAAK,UAAU,QAAQ,CAAC,QAAkB;IACxC,IAAI,CAAC;QACH,OAAO,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAA;IAC9B,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,CAAA;IACX,CAAC;AACH,CAAC;AAED,SAAS,MAAM,CAAC,IAAY;IAC1B,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAA;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,SAAS,UAAU,CAAC,QAAkB,EAAE,MAAW,EAAE,IAAY;IAC/D,MAAM,IAAI,GAAG,MAAM,EAAE,SAAS,IAAI,MAAM,EAAE,OAAO,IAAI,MAAM,EAAE,KAAK,IAAI,IAAI,CAAC,IAAI,EAAE,CAAA;IAEjF,OAAO,IAAI,IAAI,QAAQ,QAAQ,CAAC,MAAM,EAAE,CAAA;AAC1C,CAAC","sourcesContent":["/**\n * Reading what the server said about a passkey registration.\n *\n * ── Two defects that were hiding each other ─────────────────────────────────\n * The profile screen posted the attestation and then did this:\n *\n * const verification = await fetch('/auth/verify-registration', { … }).then(res => res.json())\n *\n * if (verification.verified) { …registered… }\n * else { console.error(await verification.text()); …refused… }\n *\n * `verification` is **already-parsed JSON**. It has no `.text()`. So the moment the refusal\n * branch ran it threw a `TypeError`, the outer `catch` swallowed it, and the person was told\n * the registration had *failed* — never that it had been *refused*.\n *\n * The refusal message was therefore unreachable — and behind it sat a second defect that the\n * dead branch had been hiding. There were **two near-identical keys**:\n *\n * error.user credential regist_e_ration not allowed what the code asked for — no\n * translation, in any of the five\n * error.user credential registration not allowed translated in all five — no callers\n *\n * So even if the branch had run, the screen would have printed the key itself. Each defect\n * kept the other out of sight: the dead branch meant the missing word never showed, and the\n * missing word would have been the thing that made someone look. The code now asks for the\n * spelling that has the words, and the misspelled entry is gone.\n *\n * ── Why this reads the body as text first ───────────────────────────────────\n * `createWebAuthnMiddleware` does not answer `{ verified: false }` on a refusal — it **throws**\n * `AuthError`, and what comes back is an error response whose body is not necessarily JSON.\n * `res.json()` on it rejects, which is a second way into the same wrong branch.\n *\n * So the body is read **once, as text**, and parsed only if it happens to be JSON. Text always\n * reads; that is the point. Whatever the server said survives into `reason` either way, which\n * is what was missing from the log.\n */\n\nexport type VerificationOutcome =\n /** The server confirmed it. */\n | { outcome: 'registered' }\n /**\n * The server said no. `reason` is the server's own words, for the log — never for the\n * screen: it is an error code or a passport message, in English, written for us.\n */\n | { outcome: 'refused'; reason: string }\n\n/**\n * What the verify-registration response means.\n *\n * Never throws. A registration attempt has exactly two ends a person cares about, and an\n * exception thrown while working out which one it was is how this screen lost the difference.\n */\nexport async function readVerification(response: Response): Promise<VerificationOutcome> {\n const body = await readBody(response)\n const parsed = asJson(body)\n\n if (response.ok && parsed?.verified === true) {\n return { outcome: 'registered' }\n }\n\n return { outcome: 'refused', reason: reasonFrom(response, parsed, body) }\n}\n\n/** The body, as text. A body that cannot be read at all is not a reason to lose the outcome. */\nasync function readBody(response: Response): Promise<string> {\n try {\n return await response.text()\n } catch {\n return ''\n }\n}\n\nfunction asJson(body: string): any {\n try {\n return JSON.parse(body)\n } catch {\n return null\n }\n}\n\n/**\n * The most specific thing the server said.\n *\n * `AuthError` carries `errorCode`; passport puts its own wording in `message`. Falling back to\n * the raw body keeps a plain-text error intact, and to the status when the body is empty —\n * because \"refused, and it said nothing\" is itself worth knowing in a log.\n */\nfunction reasonFrom(response: Response, parsed: any, body: string): string {\n const said = parsed?.errorCode || parsed?.message || parsed?.error || body.trim()\n\n return said || `HTTP ${response.status}`\n}\n"]}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { readVerification } from './webauthn-verification';
|
|
2
|
+
/**
|
|
3
|
+
* The profile screen could not tell a refused passkey registration from a failed one.
|
|
4
|
+
*
|
|
5
|
+
* It called `.text()` on a value it had already parsed with `.json()`, which throws a
|
|
6
|
+
* `TypeError`, which the outer `catch` turned into "registration failed". The refusal branch
|
|
7
|
+
* never ran, so the missing translation behind it never showed either.
|
|
8
|
+
*/
|
|
9
|
+
/** Just enough of `Response` for this to read. `text()` may reject; that is one of the cases. */
|
|
10
|
+
function response({ ok = true, status = 200, body = '', unreadable = false } = {}) {
|
|
11
|
+
return {
|
|
12
|
+
ok,
|
|
13
|
+
status,
|
|
14
|
+
text: async () => {
|
|
15
|
+
if (unreadable) {
|
|
16
|
+
throw new TypeError('body already consumed');
|
|
17
|
+
}
|
|
18
|
+
return body;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
describe('what the server said about a passkey registration', () => {
|
|
23
|
+
it('reads a confirmation', async () => {
|
|
24
|
+
const outcome = await readVerification(response({ body: JSON.stringify({ user: { id: 'u1' }, verified: true }) }));
|
|
25
|
+
expect(outcome).toEqual({ outcome: 'registered' });
|
|
26
|
+
});
|
|
27
|
+
it('does not throw on an error body that is not JSON', async () => {
|
|
28
|
+
/*
|
|
29
|
+
* ⚠ The case this file exists for. `createWebAuthnMiddleware` throws `AuthError` rather
|
|
30
|
+
* than answering `{ verified: false }`, so the body that comes back on a refusal need not
|
|
31
|
+
* be JSON at all. `res.json()` rejects on it — which is how the screen ended up in its
|
|
32
|
+
* catch-all saying the wrong thing.
|
|
33
|
+
*/
|
|
34
|
+
const outcome = await readVerification(response({ ok: false, status: 500, body: 'AUTHN_VERIFICATION_FAILED' }));
|
|
35
|
+
expect(outcome).toEqual({ outcome: 'refused', reason: 'AUTHN_VERIFICATION_FAILED' });
|
|
36
|
+
});
|
|
37
|
+
it('keeps the error code out of a JSON error body', async () => {
|
|
38
|
+
const outcome = await readVerification(response({ ok: false, status: 401, body: JSON.stringify({ errorCode: 'AUTHN_VERIFICATION_FAILED' }) }));
|
|
39
|
+
expect(outcome).toEqual({ outcome: 'refused', reason: 'AUTHN_VERIFICATION_FAILED' });
|
|
40
|
+
});
|
|
41
|
+
it("prefers passport's wording when there is no error code", async () => {
|
|
42
|
+
const outcome = await readVerification(response({ ok: false, status: 401, body: JSON.stringify({ message: 'already registered' }) }));
|
|
43
|
+
expect(outcome).toEqual({ outcome: 'refused', reason: 'already registered' });
|
|
44
|
+
});
|
|
45
|
+
it('says the status when the server refused without a word', async () => {
|
|
46
|
+
/* "refused, and it said nothing" is itself worth having in the log. */
|
|
47
|
+
const outcome = await readVerification(response({ ok: false, status: 403, body: '' }));
|
|
48
|
+
expect(outcome).toEqual({ outcome: 'refused', reason: 'HTTP 403' });
|
|
49
|
+
});
|
|
50
|
+
it('refuses a 200 that does not actually confirm', async () => {
|
|
51
|
+
/*
|
|
52
|
+
* A body without `verified: true` is not a confirmation, whatever the status line says.
|
|
53
|
+
* Leaning the other way would register nothing and tell the person it worked.
|
|
54
|
+
*/
|
|
55
|
+
const outcome = await readVerification(response({ body: JSON.stringify({ user: { id: 'u1' } }) }));
|
|
56
|
+
expect(outcome.outcome).toBe('refused');
|
|
57
|
+
});
|
|
58
|
+
it('does not take a truthy value for confirmation', async () => {
|
|
59
|
+
/* `verified: 'false'` is a string, and every non-empty string is truthy. */
|
|
60
|
+
const outcome = await readVerification(response({ body: JSON.stringify({ verified: 'false' }) }));
|
|
61
|
+
expect(outcome.outcome).toBe('refused');
|
|
62
|
+
});
|
|
63
|
+
it('refuses a confirmation that arrives with a failing status', async () => {
|
|
64
|
+
const outcome = await readVerification(response({ ok: false, status: 500, body: JSON.stringify({ verified: true }) }));
|
|
65
|
+
expect(outcome.outcome).toBe('refused');
|
|
66
|
+
});
|
|
67
|
+
it('never throws, even when the body cannot be read', async () => {
|
|
68
|
+
/*
|
|
69
|
+
* The shape of the original defect, generalised: working out the outcome must not be able
|
|
70
|
+
* to throw, because the screen's catch-all cannot tell "refused" from "something broke"
|
|
71
|
+
* and says the wrong one.
|
|
72
|
+
*/
|
|
73
|
+
await expect(readVerification(response({ ok: false, status: 500, unreadable: true }))).resolves.toEqual({
|
|
74
|
+
outcome: 'refused',
|
|
75
|
+
reason: 'HTTP 500'
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
//# sourceMappingURL=webauthn-verification.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webauthn-verification.test.js","sourceRoot":"","sources":["../../client/utils/webauthn-verification.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { readVerification } from './webauthn-verification'\n\n/**\n * The profile screen could not tell a refused passkey registration from a failed one.\n *\n * It called `.text()` on a value it had already parsed with `.json()`, which throws a\n * `TypeError`, which the outer `catch` turned into \"registration failed\". The refusal branch\n * never ran, so the missing translation behind it never showed either.\n */\n\n/** Just enough of `Response` for this to read. `text()` may reject; that is one of the cases. */\nfunction response({\n ok = true,\n status = 200,\n body = '',\n unreadable = false\n}: {\n ok?: boolean\n status?: number\n body?: string\n unreadable?: boolean\n} = {}): Response {\n return {\n ok,\n status,\n text: async () => {\n if (unreadable) {\n throw new TypeError('body already consumed')\n }\n\n return body\n }\n } as unknown as Response\n}\n\ndescribe('what the server said about a passkey registration', () => {\n it('reads a confirmation', async () => {\n const outcome = await readVerification(response({ body: JSON.stringify({ user: { id: 'u1' }, verified: true }) }))\n\n expect(outcome).toEqual({ outcome: 'registered' })\n })\n\n it('does not throw on an error body that is not JSON', async () => {\n /*\n * ⚠ The case this file exists for. `createWebAuthnMiddleware` throws `AuthError` rather\n * than answering `{ verified: false }`, so the body that comes back on a refusal need not\n * be JSON at all. `res.json()` rejects on it — which is how the screen ended up in its\n * catch-all saying the wrong thing.\n */\n const outcome = await readVerification(response({ ok: false, status: 500, body: 'AUTHN_VERIFICATION_FAILED' }))\n\n expect(outcome).toEqual({ outcome: 'refused', reason: 'AUTHN_VERIFICATION_FAILED' })\n })\n\n it('keeps the error code out of a JSON error body', async () => {\n const outcome = await readVerification(\n response({ ok: false, status: 401, body: JSON.stringify({ errorCode: 'AUTHN_VERIFICATION_FAILED' }) })\n )\n\n expect(outcome).toEqual({ outcome: 'refused', reason: 'AUTHN_VERIFICATION_FAILED' })\n })\n\n it(\"prefers passport's wording when there is no error code\", async () => {\n const outcome = await readVerification(\n response({ ok: false, status: 401, body: JSON.stringify({ message: 'already registered' }) })\n )\n\n expect(outcome).toEqual({ outcome: 'refused', reason: 'already registered' })\n })\n\n it('says the status when the server refused without a word', async () => {\n /* \"refused, and it said nothing\" is itself worth having in the log. */\n const outcome = await readVerification(response({ ok: false, status: 403, body: '' }))\n\n expect(outcome).toEqual({ outcome: 'refused', reason: 'HTTP 403' })\n })\n\n it('refuses a 200 that does not actually confirm', async () => {\n /*\n * A body without `verified: true` is not a confirmation, whatever the status line says.\n * Leaning the other way would register nothing and tell the person it worked.\n */\n const outcome = await readVerification(response({ body: JSON.stringify({ user: { id: 'u1' } }) }))\n\n expect(outcome.outcome).toBe('refused')\n })\n\n it('does not take a truthy value for confirmation', async () => {\n /* `verified: 'false'` is a string, and every non-empty string is truthy. */\n const outcome = await readVerification(response({ body: JSON.stringify({ verified: 'false' }) }))\n\n expect(outcome.outcome).toBe('refused')\n })\n\n it('refuses a confirmation that arrives with a failing status', async () => {\n const outcome = await readVerification(\n response({ ok: false, status: 500, body: JSON.stringify({ verified: true }) })\n )\n\n expect(outcome.outcome).toBe('refused')\n })\n\n it('never throws, even when the body cannot be read', async () => {\n /*\n * The shape of the original defect, generalised: working out the outcome must not be able\n * to throw, because the screen's catch-all cannot tell \"refused\" from \"something broke\"\n * and says the wrong one.\n */\n await expect(readVerification(response({ ok: false, status: 500, unreadable: true }))).resolves.toEqual({\n outcome: 'refused',\n reason: 'HTTP 500'\n })\n })\n})\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/auth-ui",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.18",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -37,10 +37,10 @@
|
|
|
37
37
|
"@operato/p13n": "^10.0.0",
|
|
38
38
|
"@operato/popup": "^10.0.0",
|
|
39
39
|
"@operato/styles": "^10.0.0",
|
|
40
|
-
"@things-factory/auth-base": "^10.1.
|
|
40
|
+
"@things-factory/auth-base": "^10.1.18",
|
|
41
41
|
"@things-factory/more-base": "^10.1.16",
|
|
42
42
|
"base64url": "^3.0.1",
|
|
43
43
|
"clipboard": "^2.0.6"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "608fe1a70f430ae9c3aa20497173cecc8121373e"
|
|
46
46
|
}
|
package/things-factory.config.js
CHANGED
|
@@ -5,14 +5,9 @@ export default {
|
|
|
5
5
|
route,
|
|
6
6
|
routes: [
|
|
7
7
|
{ tagname: 'auth-profile', page: 'profile' },
|
|
8
|
-
{ tagname: 'application-page', page: 'application' },
|
|
9
8
|
{ tagname: 'applications-page', page: 'applications' },
|
|
10
|
-
{ tagname: 'application-register', page: 'application-register' },
|
|
11
9
|
{ tagname: 'app-bindings-page', page: 'app-bindings' },
|
|
12
|
-
{ tagname: 'app-binding-page', page: 'app-binding' },
|
|
13
|
-
{ tagname: 'appliance-register', page: 'appliance-register' },
|
|
14
10
|
{ tagname: 'appliance-home', page: 'appliance-home' },
|
|
15
|
-
{ tagname: 'appliance-page', page: 'appliance' },
|
|
16
11
|
{ tagname: 'role-list-page', page: 'roles' },
|
|
17
12
|
{ tagname: 'role-privilege-page', page: 'role-privileges' },
|
|
18
13
|
{ tagname: 'user-list-page', page: 'users' },
|
package/translations/en.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"button.drop": "Take away",
|
|
17
17
|
"button.edit privileges": "Edit privileges",
|
|
18
18
|
"button.generate new access token": "generate new access token",
|
|
19
|
+
"button.generate new secret": "generate new secret",
|
|
19
20
|
"button.go to home": "go to home",
|
|
20
21
|
"button.hide": "hide",
|
|
21
22
|
"button.logout": "logout",
|
|
@@ -23,6 +24,7 @@
|
|
|
23
24
|
"button.register": "register",
|
|
24
25
|
"button.remove from domain": "Remove from domain",
|
|
25
26
|
"button.remove owner": "remove owner",
|
|
27
|
+
"button.renew access token": "renew access token",
|
|
26
28
|
"button.reveal": "reveal",
|
|
27
29
|
"button.save": "save",
|
|
28
30
|
"button.security-key registration": "security key registration",
|
|
@@ -32,6 +34,7 @@
|
|
|
32
34
|
"button.terminate contract": "terminate",
|
|
33
35
|
"button.transfer owner": "transfer owner",
|
|
34
36
|
"button.unlock": "unlock",
|
|
37
|
+
"button.update": "update",
|
|
35
38
|
"error.cannot_modify_system_subdomain": "the 'system' subdomain cannot be modified.",
|
|
36
39
|
"error.domain not allowed": "the '{subdomain}' domain is not allowed for this user.",
|
|
37
40
|
"error.new-password-and-confirm-password-do-not-match": "new password and confirm password do not match",
|
|
@@ -43,10 +46,14 @@
|
|
|
43
46
|
"error.user-locked": "user account is locked",
|
|
44
47
|
"error.value is empty": "{value} is empty",
|
|
45
48
|
"error.x already exists in y": "{x} already exists in {y}",
|
|
49
|
+
"field.access token url": "access token url",
|
|
46
50
|
"field.accessed-at": "accessed at",
|
|
51
|
+
"field.app key": "app key",
|
|
47
52
|
"field.application icon": "application icon",
|
|
48
53
|
"field.application url": "application url",
|
|
49
54
|
"field.attributes": "attributes",
|
|
55
|
+
"field.auth url": "auth url",
|
|
56
|
+
"field.available scopes": "available scopes",
|
|
50
57
|
"field.brand": "brand",
|
|
51
58
|
"field.client-id": "client id",
|
|
52
59
|
"field.client-secret": "client secret",
|
|
@@ -96,6 +103,7 @@
|
|
|
96
103
|
"field.user-type": "user type",
|
|
97
104
|
"field.username": "user id",
|
|
98
105
|
"field.username or email": "username or email",
|
|
106
|
+
"field.webhook": "webhook",
|
|
99
107
|
"label.access token": "access token",
|
|
100
108
|
"label.activated": "activated",
|
|
101
109
|
"label.activated-user": "activated",
|
|
@@ -166,8 +174,10 @@
|
|
|
166
174
|
"text.appliance": "terminal appliance",
|
|
167
175
|
"text.appliance credential": "terminal appliance credential",
|
|
168
176
|
"text.appliances": "appliances",
|
|
177
|
+
"text.application": "application",
|
|
169
178
|
"text.application management": "application management",
|
|
170
179
|
"text.applications": "applications",
|
|
180
|
+
"text.are you sure you want to delete x": "delete {x}? this cannot be undone.",
|
|
171
181
|
"text.are_you_sure_to_delete_x": "are you sure to delete {x}",
|
|
172
182
|
"text.are_you_sure_to_remove_domain_owner": "are you sure to remove {x} as a domain owner?",
|
|
173
183
|
"text.are_you_sure_to_terminate_contract": "are you sure to terminate contract?",
|
|
@@ -177,6 +187,9 @@
|
|
|
177
187
|
"text.attribute management": "attribute management",
|
|
178
188
|
"text.auth profile": "user profile",
|
|
179
189
|
"text.auth-provider management": "auth provider management",
|
|
190
|
+
"text.binding": "binding",
|
|
191
|
+
"text.binding credential": "binding credential",
|
|
192
|
+
"text.bound application": "bound application",
|
|
180
193
|
"text.bound applications": "bound applications",
|
|
181
194
|
"text.cannot be undone": "Cannot be undone",
|
|
182
195
|
"text.cannot_delete": "cannot be deleted",
|
|
@@ -186,8 +199,10 @@
|
|
|
186
199
|
"text.confirm password": "confirm password",
|
|
187
200
|
"text.copied": "copied",
|
|
188
201
|
"text.copying needs a secure connection": "copying needs a secure connection — select the value and copy it by hand",
|
|
202
|
+
"text.could not be saved": "could not be saved — see the message above for the reason",
|
|
189
203
|
"text.could not register": "could not register — see the message above for the reason",
|
|
190
204
|
"text.create role": "create role",
|
|
205
|
+
"text.credentials": "credentials",
|
|
191
206
|
"text.current password": "current password",
|
|
192
207
|
"text.delete account warning message": "this action cannot be undone. this will permanently delete all about this account.\n\nplease type in the email and password of this account to confirm.",
|
|
193
208
|
"text.deleted_successfully": "deleted successfully",
|
|
@@ -200,7 +215,9 @@
|
|
|
200
215
|
"text.domain owners": "domain owners",
|
|
201
216
|
"text.domain_name_change_warning": "are you sure you want to change the domain name? (the subdomain will remain unchanged.)",
|
|
202
217
|
"text.email is not matched": "email is not matched.",
|
|
218
|
+
"text.endpoints": "endpoints",
|
|
203
219
|
"text.env-var-list-page": "environment variable list",
|
|
220
|
+
"text.expired": "expired",
|
|
204
221
|
"text.expires at x": "expires {x}",
|
|
205
222
|
"text.failed": "failed",
|
|
206
223
|
"text.failed to fetch": "could not read the list",
|
|
@@ -238,6 +255,7 @@
|
|
|
238
255
|
"text.please try again": "Try again in a moment.",
|
|
239
256
|
"text.please_fill_all_required_fields": "please fill all required fields",
|
|
240
257
|
"text.privilege no longer declared": "no code requires this privilege anymore",
|
|
258
|
+
"text.register new appliance": "register new appliance",
|
|
241
259
|
"text.register new application": "register a new application",
|
|
242
260
|
"text.registered roles": "registered roles",
|
|
243
261
|
"text.remaining time": "{days} days {hours} hours {mins} mins {secs} seconds remain",
|
|
@@ -254,6 +272,11 @@
|
|
|
254
272
|
"text.struck through are not installed here": "Struck through are privileges this installation does not have.",
|
|
255
273
|
"text.subdomain_change_critical_warning": "warning: changing the subdomain will alter the full access URL (/{type}/{subdomain}/...) and may invalidate existing external links, bookmarks, and active user sessions. please proceed with caution.",
|
|
256
274
|
"text.synchronize": "synchronize",
|
|
275
|
+
"text.the appliance could not be loaded": "the appliance could not be loaded",
|
|
276
|
+
"text.the application could not be loaded": "the application could not be loaded",
|
|
277
|
+
"text.the binding could not be loaded": "the binding could not be loaded",
|
|
278
|
+
"text.the current access token stops working immediately": "the current access token stops working immediately, and anything using it has to be given the new one.",
|
|
279
|
+
"text.the current secret stops working immediately": "the current secret stops working immediately, and anything using it has to be given the new one.",
|
|
257
280
|
"text.the list could not be loaded": "the list could not be loaded",
|
|
258
281
|
"text.they lose access to this domain but keep the account": "They lose access to this domain. The account itself remains, so you can add them again later.",
|
|
259
282
|
"text.this domain is top level and inherits from nowhere": "top level — nothing is inherited here",
|
|
@@ -265,6 +288,8 @@
|
|
|
265
288
|
"text.user invitation prompt": "Please enter the username or email address of the user you wish to invite.",
|
|
266
289
|
"text.user management": "user management",
|
|
267
290
|
"text.view only": "Inherited role — view only",
|
|
291
|
+
"text.where an integration asks for an authorization code": "where an integration asks for an authorization code",
|
|
292
|
+
"text.where that code is exchanged for an access token": "where that code is exchanged for an access token",
|
|
268
293
|
"text.without a role they see nothing": "Without a role there will be no screens for them.",
|
|
269
294
|
"text.x is required": "Please enter the {x}.",
|
|
270
295
|
"text.x modules y privileges": "{x} modules · {y} privileges",
|