saasufy-components 10.5.3 → 10.5.5
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/README.md +12 -7
- package/collection-adder.js +45 -40
- package/confirm-modal.js +10 -4
- package/input-provider.js +8 -3
- package/log-in-form.js +6 -5
- package/log-out.js +10 -0
- package/model-input.js +6 -2
- package/oauth-handler.js +37 -4
- package/oauth-link.js +20 -17
- package/package.json +1 -1
- package/utils.js +61 -16
package/README.md
CHANGED
|
@@ -73,6 +73,11 @@ In order to render HTML which was generated dynamically, you need to use triple
|
|
|
73
73
|
|
|
74
74
|
Saasufy components support nested expressions but you should not inject untrusted/unsanitized input via a nested expression as it can make your application vulerable to XSS attacks.
|
|
75
75
|
|
|
76
|
+
Escaping covers element content and quoted attribute values (a newline becomes `<br>`). Two contexts it cannot protect, so avoid them with values you do not control:
|
|
77
|
+
|
|
78
|
+
- Unquoted attributes, as in `<div class={{value}}>`, which the value can add attributes to. Always quote: `<div class="{{value}}">`.
|
|
79
|
+
- Attributes which are code, such as `onclick` or `style`, since the browser decodes entities before the handler runs. Put the value in a `data-` attribute and read it from your handler instead.
|
|
80
|
+
|
|
76
81
|
When an expression fails to render (I.e. throws an error), it will remain unchanged in its original expression format within the component/template. If an expression is nested inside a child component, the parent will attempt to evaluate it first. If it fails, then it will remain as-is and the child may later also attempt to evaluate it as part of its own rendering (e.g. once the child provides the missing state).
|
|
77
82
|
|
|
78
83
|
### Styling
|
|
@@ -686,7 +691,7 @@ Used for displaying and editing a single field of a model instance in realtime.
|
|
|
686
691
|
- `type`: The type of the input component; can be `text`, `number`, `select`, `textarea`, `checkbox` or `file`.
|
|
687
692
|
- `placeholder`: Can be used to set the placeholder text on the inner input element.
|
|
688
693
|
- `consumers`: This allows you to connect this `model-input` to other elements on your page. It takes a list of selectors with optional attributes to target in the format `element-selector:attribute-name`. For example `.my-input:value` will find all elements with a `my-input` class and update their `value` attributes with the value of the `model-input` component in real-time. You can specify multiple selectors separated by commas such as `.my-input,my-div`; in this case, because attribute names are not specified, values will be injected into the `value` attribute (for input elements) or into the `innerHTML` property (for other kinds of elements). The default attribute/property depends on the element type. Note that `model-input` elements of the `file` type are treated as write-only unless the `consumers` attribute is present.
|
|
689
|
-
- `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers.
|
|
694
|
+
- `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers. Markup in the template itself is preserved, which is how a consumer is given HTML; the `{{value}}` substituted into it is escaped. With no `provider-template`, the value is escaped before being written to a consumer's `innerHTML`.
|
|
690
695
|
- `show-error-message`: If this attribute is present, an error message will be displayed above the input if it fails to update the value.
|
|
691
696
|
- `error-consumers`: Sends error messages to other elements on your page instead of (or as well as) showing them next to the input. It takes the same list of selectors with optional attributes as `consumers`, in the format `element-selector:attribute-name`. This is useful when a message appearing beside the input would move the surrounding layout; the messages of a list of rows can be collected into a single area above the list, for example. Each input should be given a target of its own, since inputs which share one will overwrite each other's messages. The message is cleared (an empty string is sent) as soon as the input saves successfully. Can be used with or without `show-error-message`.
|
|
692
697
|
- `error-provider-template`: A template string within which the `{{value}}` of an error message can be injected before passing it to the elements named by `error-consumers`. Serves the same purpose for errors as `provider-template` does for values. Note that an error message is escaped before the template is applied, so markup in the message itself is shown as text while markup in the template is not.
|
|
@@ -694,7 +699,7 @@ Used for displaying and editing a single field of a model instance in realtime.
|
|
|
694
699
|
- `height`: Allows you to set the height of the inner `input` element programmatically.
|
|
695
700
|
- `default-value`: A default value to show the user if the underlying model field's value is null or undefined. Note that setting a default value will not affect the underlying model's value.
|
|
696
701
|
- `value`: Used to set the model's value.
|
|
697
|
-
- `computable-value`: If this attribute is present, it will be possible to execute expressions using the `{{expression}}` syntax inside the `value` attribute.
|
|
702
|
+
- `computable-value`: If this attribute is present, it will be possible to execute expressions using the `{{expression}}` syntax inside the `value` attribute. The `value` is evaluated as JavaScript, so only use this attribute where the `value` is written by you; do not turn it on for an input whose `value` is fed from a record or from another component.
|
|
698
703
|
- `slice-to`: Optional attribute which can be set to a number to trim strings down to a maximum number of characters. This is useful when dealing with potentially very long field values. Extra care should be taken when using this attribute on a `model-input` element as it will cause values to be overwritten with shortened values if the user edits the input box. Note also that it may affect caching if the same field is being referenced in multiple parts of the application at the same time within the same `socket-provider`.
|
|
699
704
|
- `hide-error-logs`: By default, this component will log errors to the console. If set, this attribute will suppress such errors from showing up on the console.
|
|
700
705
|
- `autocapitalize`: Can be `on` or `off` - It will set the auto-capitalize attribute on the inner input element. This is useful for mobile devices to enable or disable auto-capitalization of the first character which is typed into the input element.
|
|
@@ -809,12 +814,12 @@ This component can be configured to provide its data to multiple components (con
|
|
|
809
814
|
- `disable-instant-flush`: If specified, changes will only be saved once they have settled (E.g. after the element has lost focus or when the enter key is pressed).
|
|
810
815
|
- `placeholder`: Can be used to set the placeholder text on the inner input element.
|
|
811
816
|
- `consumers`: This allows you to connect this `input-provider` to other elements on your page. It takes a list of selectors with optional attributes to target in the format `element-selector:attribute-name`. For example `.my-input:value` will find all elements with a `my-input` class and update their `value` attributes with the value of the `input-provider` component in real-time. You can specify multiple selectors separated by commas such as `.my-input,my-div`; in this case, because attribute names are not specified, values will be injected into the `value` attribute (for input elements) or into the `innerHTML` property (for other kinds of elements). The default attribute/property depends on the element type.
|
|
812
|
-
- `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers.
|
|
817
|
+
- `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers. Markup in the template itself is preserved, which is how a consumer is given HTML; the `{{value}}` substituted into it is escaped. With no `provider-template`, the value is escaped before being written to a consumer's `innerHTML`.
|
|
813
818
|
- `debounce-delay`: The delay in milliseconds to wait before triggering a change event. This is useful to batch multiple updates together (as is common when user is typing). Default is 800ms.
|
|
814
819
|
- `options`: A comma-separated list of options to provide - This only works if the input type is `select`. Each option is in the format `label:type=value`; the `:type` and `=value` parts are optional. If the value is not specified, the label is used as the value. The type can be `string` (default), `number` or `boolean` and determines the type of the value which will be passed to consumers when the option is selected; for example `options="30 min:number=30,1 hour:number=60"` will pass the number `30` or `60` instead of the string `'30'` or `'60'`.
|
|
815
820
|
- `height`: Allows you to set the height of the inner `input` element programmatically.
|
|
816
821
|
- `value`: Used to set the input's value.
|
|
817
|
-
- `computable-value`: If this attribute is present, it will be possible to execute expressions using the `{{expression}}` syntax inside the `value` attribute.
|
|
822
|
+
- `computable-value`: If this attribute is present, it will be possible to execute expressions using the `{{expression}}` syntax inside the `value` attribute. The `value` is evaluated as JavaScript, so only use this attribute where the `value` is written by you; do not turn it on for an input whose `value` is fed from a record or from another component.
|
|
818
823
|
- `autocapitalize`: Can be `on` or `off` - It will set the auto-capitalize attribute on the inner input element. This is useful for mobile devices to enable or disable auto-capitalization of the first character which is typed into the input element.
|
|
819
824
|
- `autocorrect`: Can be `on` or `off` - It will set the auto-correct attribute on the inner input element. This is useful for mobile devices to enable or disable auto-correct.
|
|
820
825
|
- `input-props`: Can be used to set additional attributes on the inner `input` element. Must be in the format `attr1=value1,attr2=value2`.
|
|
@@ -912,7 +917,7 @@ The following example shows how to bind an `input-provider` component to an `inp
|
|
|
912
917
|
**Attributes**
|
|
913
918
|
|
|
914
919
|
- `consumers`: This allows you to connect this `input-transformer` to other elements on your page. It takes a list of selectors with optional attributes to target in the format `element-selector:attribute-name`. For example `.my-input:value` will find all elements with a `my-input` class and update their `value` attributes with the value of the `input-provider` component in real-time. You can specify multiple selectors separated by commas such as `.my-input,my-div`; in this case, because attribute names are not specified, values will be injected into the `value` attribute (for input elements) or into the `innerHTML` property (for other kinds of elements). The default attribute/property depends on the element type.
|
|
915
|
-
- `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers.
|
|
920
|
+
- `provider-template`: A template string within which the `{{value}}` can be injected before passing to consumers. Markup in the template itself is preserved, which is how a consumer is given HTML; the `{{value}}` substituted into it is escaped. With no `provider-template`, the value is escaped before being written to a consumer's `innerHTML`.
|
|
916
921
|
- `output-type`: The default output type is `string`. This property can be used to convert the output to either `boolean` or `number`.
|
|
917
922
|
- `debounce-delay`: The delay in milliseconds to wait before triggering a change event. This is useful to batch multiple updates together. Default is 0ms.
|
|
918
923
|
|
|
@@ -985,7 +990,7 @@ Logging out of Keycloak as well as of the app:
|
|
|
985
990
|
|
|
986
991
|
**Attributes**
|
|
987
992
|
|
|
988
|
-
- `logout-url`: The log out endpoint of the OAuth provider; for OpenID Connect providers this is the `end_session_endpoint`. If it is not specified, the component only deauthenticates the socket. The redirect is skipped if the user did not log in via an OAuth provider.
|
|
993
|
+
- `logout-url`: The log out endpoint of the OAuth provider; for OpenID Connect providers this is the `end_session_endpoint`. If it is not specified, the component only deauthenticates the socket. The redirect is skipped if the user did not log in via an OAuth provider. It must be an `http` or `https` URL; relative paths are supported and are resolved against your app's origin.
|
|
989
994
|
- `provider`: The name of the OAuth provider, as specified on the `Authentication` page of your Saasufy control panel. If it is set, the redirect only happens when the user logged in via that specific provider; this is useful when your app offers several different ways to log in.
|
|
990
995
|
- `client-id`: The client ID to pass to the provider as the `client_id` query parameter. OpenID Connect providers require either this or an ID token in order to log the user out without asking them to confirm.
|
|
991
996
|
- `post-logout-redirect-uri`: The URL to send the user back to once the provider has logged them out, passed as the `post_logout_redirect_uri` query parameter. It usually needs to be registered with the provider in advance. If it is not specified, the user is left on the provider's own logged out page.
|
|
@@ -1046,7 +1051,7 @@ When using an OAuth provider, the callback URL which you register with the provi
|
|
|
1046
1051
|
|
|
1047
1052
|
- `provider` (required): The name of the provider. It must match the provider name specified on the `Authentication` page of your Saasufy control panel and also the value provided to the `oauth-link` component at the start of the OAuth flow.
|
|
1048
1053
|
- `success-location-hash`: If authentication is successful, the browser's `location.hash` will be set to this value. It can be used to redirect the user to their dashboard, for example.
|
|
1049
|
-
- `success-url`: If authentication is successful, the browser's `location.href` will be set to this value. It can be used to redirect the user to their dashboard, for example.
|
|
1054
|
+
- `success-url`: If authentication is successful, the browser's `location.href` will be set to this value. It can be used to redirect the user to their dashboard, for example. It must be an `http` or `https` URL; relative paths are supported and are resolved against the current page.
|
|
1050
1055
|
- `redirect-uri`: A URI to redirect the user to once OAuth is successful. This attribute may be required depending on the OAuth provider/client.
|
|
1051
1056
|
- `extra-data`: This attribute can be used to pass additional data to the OAuth `getAccessTokenURL` endpoint. For Google OAuth, for example, an additional `redirect_uri` field is required, so it should be set using `extra-data="redirect_uri=http://localhost.com:8000/"` (substitute the URI with your own).
|
|
1052
1057
|
- `navigate-event-path`: If authentication is successful and a path is set via this attribute, the component will emit a `navigate` event which will bubble up the component hierarchy and can be used by a parent component to perform the success redirection. This approach can be used as an alternative to `success-location-hash` or `success-url` for doing the final redirect.
|
package/collection-adder.js
CHANGED
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
convertStringToFieldParams,
|
|
5
5
|
getTypeCastFunction,
|
|
6
6
|
formatError,
|
|
7
|
-
renderTemplate
|
|
7
|
+
renderTemplate,
|
|
8
|
+
toSafeHTML
|
|
8
9
|
} from './utils.js';
|
|
9
10
|
import AGCollection from '/node_modules/ag-collection/ag-collection.js';
|
|
10
11
|
|
|
@@ -281,10 +282,14 @@ class CollectionAdder extends SocketConsumer {
|
|
|
281
282
|
let autocapitalize = this.getAttribute('autocapitalize');
|
|
282
283
|
let autocorrect = this.getAttribute('autocorrect');
|
|
283
284
|
|
|
285
|
+
// Every value interpolated into the form markup below comes from an
|
|
286
|
+
// attribute, which the browser has already decoded, so each one is escaped
|
|
287
|
+
// on the way back into markup. Escaping is a no-op on entities the template
|
|
288
|
+
// layer produced, so labels keep rendering exactly as they did.
|
|
284
289
|
let extraAttributesString = `${
|
|
285
|
-
autocapitalize ? ` autocapitalize="${autocapitalize}"` : ''
|
|
290
|
+
autocapitalize ? ` autocapitalize="${toSafeHTML(autocapitalize)}"` : ''
|
|
286
291
|
}${
|
|
287
|
-
autocorrect ? ` autocorrect="${autocorrect}"` : ''
|
|
292
|
+
autocorrect ? ` autocorrect="${toSafeHTML(autocorrect)}"` : ''
|
|
288
293
|
}`;
|
|
289
294
|
|
|
290
295
|
if (this.collection) this.collection.destroy();
|
|
@@ -309,72 +314,72 @@ class CollectionAdder extends SocketConsumer {
|
|
|
309
314
|
let optionLabel = optionLabels[field] == null ? 'Options' : optionLabels[field];
|
|
310
315
|
if (inputType === 'checkbox') {
|
|
311
316
|
items.push(
|
|
312
|
-
`<div class="collection-adder-field-container" data-field="${field}">
|
|
317
|
+
`<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
|
|
313
318
|
<div class="collection-adder-field-error-container error"></div>
|
|
314
319
|
<label>
|
|
315
320
|
${
|
|
316
|
-
inputLabel
|
|
321
|
+
toSafeHTML(inputLabel)
|
|
317
322
|
}
|
|
318
323
|
<input class="collection-adder-input" type="${
|
|
319
|
-
inputType
|
|
324
|
+
toSafeHTML(inputType)
|
|
320
325
|
}" name="${
|
|
321
|
-
field
|
|
326
|
+
toSafeHTML(field)
|
|
322
327
|
}" />
|
|
323
328
|
</label>
|
|
324
329
|
</div>`
|
|
325
330
|
);
|
|
326
331
|
} else if (inputType === 'select') {
|
|
327
332
|
items.push(
|
|
328
|
-
`<div class="collection-adder-field-container" data-field="${field}">
|
|
333
|
+
`<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
|
|
329
334
|
<div class="collection-adder-field-error-container error"></div>
|
|
330
335
|
<label>
|
|
331
336
|
${
|
|
332
|
-
inputLabel
|
|
337
|
+
toSafeHTML(inputLabel)
|
|
333
338
|
}
|
|
334
|
-
<select name="${field}" class="collection-adder-input collection-adder-select">
|
|
335
|
-
<option value="" selected disabled hidden>${optionLabel}</option>
|
|
336
|
-
${inputTypeParams.map(param => `<option value="${param}">${param}</option>`).join('')}
|
|
339
|
+
<select name="${toSafeHTML(field)}" class="collection-adder-input collection-adder-select">
|
|
340
|
+
<option value="" selected disabled hidden>${toSafeHTML(optionLabel)}</option>
|
|
341
|
+
${inputTypeParams.map(param => `<option value="${toSafeHTML(param)}">${toSafeHTML(param)}</option>`).join('')}
|
|
337
342
|
</select>
|
|
338
343
|
</label>
|
|
339
344
|
</div>`
|
|
340
345
|
);
|
|
341
346
|
} else if (inputType === 'text-select') {
|
|
342
347
|
items.push(
|
|
343
|
-
`<div class="collection-adder-field-container" data-field="${field}">
|
|
348
|
+
`<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
|
|
344
349
|
<div class="collection-adder-field-error-container error"></div>
|
|
345
350
|
<div class="collection-adder-select-text-row">
|
|
346
351
|
<input class="collection-adder-input" type="text" name="${
|
|
347
|
-
field
|
|
352
|
+
toSafeHTML(field)
|
|
348
353
|
}" placeholder="${
|
|
349
|
-
inputLabel
|
|
354
|
+
toSafeHTML(inputLabel)
|
|
350
355
|
}"${extraAttributesString} />
|
|
351
356
|
<select class="collection-adder-select">
|
|
352
|
-
<option value="" selected disabled hidden>${optionLabel}</option>
|
|
353
|
-
${inputTypeParams.map(param => `<option value="${param}">${param}</option>`).join('')}
|
|
357
|
+
<option value="" selected disabled hidden>${toSafeHTML(optionLabel)}</option>
|
|
358
|
+
${inputTypeParams.map(param => `<option value="${toSafeHTML(param)}">${toSafeHTML(param)}</option>`).join('')}
|
|
354
359
|
</select>
|
|
355
360
|
</div>
|
|
356
361
|
</div>`
|
|
357
362
|
);
|
|
358
363
|
} else if (inputType === 'radio') {
|
|
359
364
|
items.push(
|
|
360
|
-
`<div class="collection-adder-field-container" data-field="${field}">
|
|
365
|
+
`<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
|
|
361
366
|
<div class="collection-adder-field-error-container error"></div>
|
|
362
367
|
<div>
|
|
363
368
|
${
|
|
364
|
-
inputLabel
|
|
369
|
+
toSafeHTML(inputLabel)
|
|
365
370
|
}
|
|
366
371
|
${
|
|
367
372
|
inputTypeParams.map(
|
|
368
373
|
param => (
|
|
369
374
|
`<label>
|
|
370
375
|
<input class="collection-adder-radio" type="${
|
|
371
|
-
inputType
|
|
376
|
+
toSafeHTML(inputType)
|
|
372
377
|
}" name="${
|
|
373
|
-
field
|
|
374
|
-
}" value
|
|
375
|
-
param
|
|
376
|
-
} />
|
|
377
|
-
${param}
|
|
378
|
+
toSafeHTML(field)
|
|
379
|
+
}" value="${
|
|
380
|
+
toSafeHTML(param)
|
|
381
|
+
}" />
|
|
382
|
+
${toSafeHTML(param)}
|
|
378
383
|
</label>`
|
|
379
384
|
)
|
|
380
385
|
).join('')
|
|
@@ -384,49 +389,49 @@ class CollectionAdder extends SocketConsumer {
|
|
|
384
389
|
);
|
|
385
390
|
} else if (inputType === 'textarea') {
|
|
386
391
|
items.push(
|
|
387
|
-
`<div class="collection-adder-field-container" data-field="${field}">
|
|
392
|
+
`<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
|
|
388
393
|
<div class="collection-adder-field-error-container error"></div>
|
|
389
394
|
<textarea class="collection-adder-input" name="${
|
|
390
|
-
field
|
|
395
|
+
toSafeHTML(field)
|
|
391
396
|
}" placeholder="${
|
|
392
|
-
inputLabel
|
|
397
|
+
toSafeHTML(inputLabel)
|
|
393
398
|
}"${extraAttributesString}></textarea>
|
|
394
399
|
</div>`
|
|
395
400
|
);
|
|
396
401
|
} else if (inputType === 'file') {
|
|
397
402
|
items.push(
|
|
398
|
-
`<div class="collection-adder-field-container" data-field="${field}">
|
|
403
|
+
`<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
|
|
399
404
|
<div class="collection-adder-field-error-container error"></div>
|
|
400
405
|
<input class="collection-adder-input" type="${
|
|
401
|
-
inputType
|
|
406
|
+
toSafeHTML(inputType)
|
|
402
407
|
}" name="${
|
|
403
|
-
field
|
|
408
|
+
toSafeHTML(field)
|
|
404
409
|
}"${extraAttributesString} />
|
|
405
410
|
</div>`
|
|
406
411
|
);
|
|
407
412
|
} else if (inputType === 'number') {
|
|
408
413
|
items.push(
|
|
409
|
-
`<div class="collection-adder-field-container" data-field="${field}">
|
|
414
|
+
`<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
|
|
410
415
|
<div class="collection-adder-field-error-container error"></div>
|
|
411
416
|
<input class="collection-adder-input" type="${
|
|
412
|
-
inputType
|
|
417
|
+
toSafeHTML(inputType)
|
|
413
418
|
}" name="${
|
|
414
|
-
field
|
|
419
|
+
toSafeHTML(field)
|
|
415
420
|
}" placeholder="${
|
|
416
|
-
inputLabel
|
|
421
|
+
toSafeHTML(inputLabel)
|
|
417
422
|
}" step="any"${extraAttributesString} />
|
|
418
423
|
</div>`
|
|
419
424
|
);
|
|
420
425
|
} else {
|
|
421
426
|
items.push(
|
|
422
|
-
`<div class="collection-adder-field-container" data-field="${field}">
|
|
427
|
+
`<div class="collection-adder-field-container" data-field="${toSafeHTML(field)}">
|
|
423
428
|
<div class="collection-adder-field-error-container error"></div>
|
|
424
429
|
<input class="collection-adder-input" type="${
|
|
425
|
-
inputType
|
|
430
|
+
toSafeHTML(inputType)
|
|
426
431
|
}" name="${
|
|
427
|
-
field
|
|
432
|
+
toSafeHTML(field)
|
|
428
433
|
}" placeholder="${
|
|
429
|
-
inputLabel
|
|
434
|
+
toSafeHTML(inputLabel)
|
|
430
435
|
}"${extraAttributesString} />
|
|
431
436
|
</div>`
|
|
432
437
|
);
|
|
@@ -438,7 +443,7 @@ class CollectionAdder extends SocketConsumer {
|
|
|
438
443
|
<div class="collection-adder-message-container"></div>
|
|
439
444
|
<div class="collection-adder-form-content">
|
|
440
445
|
${items.join('')}
|
|
441
|
-
${hideSubmitButton ? '' : `<input type="submit" value="${submitButtonLabel}" />`}
|
|
446
|
+
${hideSubmitButton ? '' : `<input type="submit" value="${toSafeHTML(submitButtonLabel)}" />`}
|
|
442
447
|
</div>
|
|
443
448
|
</form>
|
|
444
449
|
`;
|
package/confirm-modal.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import './overlay-modal.js';
|
|
2
|
+
import { toSafeHTML } from './utils.js';
|
|
2
3
|
|
|
3
4
|
class ConfirmModal extends HTMLElement {
|
|
4
5
|
constructor() {
|
|
@@ -65,14 +66,19 @@ class ConfirmModal extends HTMLElement {
|
|
|
65
66
|
let confirmButtonLabel = this.getAttribute('confirm-button-label') || 'Confirm';
|
|
66
67
|
let cancelButtonLabel = this.getAttribute('cancel-button-label') || 'Cancel';
|
|
67
68
|
|
|
69
|
+
// These are documented as text and typically come from an attribute which a
|
|
70
|
+
// template wrote, such as confirm-message="Delete {{Product.name}}?". The
|
|
71
|
+
// browser decodes entities when the attribute is parsed, so getAttribute
|
|
72
|
+
// hands back the original characters and they have to be escaped again
|
|
73
|
+
// before going into markup here.
|
|
68
74
|
this.innerHTML = `
|
|
69
75
|
<overlay-modal${this.isHidden ? ' style="display: none;"' : ''}>
|
|
70
|
-
<div slot="title">${heading}</div>
|
|
76
|
+
<div slot="title">${toSafeHTML(heading)}</div>
|
|
71
77
|
<div class="confirm-modal-content" slot="content">
|
|
72
|
-
<div>${message}</div>
|
|
78
|
+
<div>${toSafeHTML(message)}</div>
|
|
73
79
|
<div class="confirm-modal-buttons-container">
|
|
74
|
-
<input class="modal-confirm-button" type="button" value="${confirmButtonLabel}" />
|
|
75
|
-
<input class="modal-cancel-button" type="button" value="${cancelButtonLabel}" />
|
|
80
|
+
<input class="modal-confirm-button" type="button" value="${toSafeHTML(confirmButtonLabel)}" />
|
|
81
|
+
<input class="modal-cancel-button" type="button" value="${toSafeHTML(cancelButtonLabel)}" />
|
|
76
82
|
</div>
|
|
77
83
|
</div>
|
|
78
84
|
</overlay-modal>
|
package/input-provider.js
CHANGED
|
@@ -4,7 +4,8 @@ import {
|
|
|
4
4
|
updateConsumerElements,
|
|
5
5
|
convertStringToFieldParams,
|
|
6
6
|
convertStringToFieldTypeValues,
|
|
7
|
-
getTypeCastFunction
|
|
7
|
+
getTypeCastFunction,
|
|
8
|
+
toSafeHTML
|
|
8
9
|
} from './utils.js';
|
|
9
10
|
|
|
10
11
|
const DEFAULT_DEBOUNCE_DELAY = 800;
|
|
@@ -141,16 +142,20 @@ class InputProvider extends HTMLElement {
|
|
|
141
142
|
if (options != null) {
|
|
142
143
|
let fieldTypeValues = convertStringToFieldTypeValues(options, true, true);
|
|
143
144
|
optionValues = new Map();
|
|
145
|
+
// The options attribute can be written by a consumer rather than by hand,
|
|
146
|
+
// in which case it carries whatever a record held. Escaping is a no-op on
|
|
147
|
+
// the entities the template layer already produced, so a label keeps
|
|
148
|
+
// rendering as it did while markup in it stays text.
|
|
144
149
|
let optionElements = fieldTypeValues
|
|
145
150
|
.map((option) => {
|
|
146
151
|
if (option.field === placeholder) {
|
|
147
|
-
return `<option value="" selected class="select-default-option">${option.field}</option>`;
|
|
152
|
+
return `<option value="" selected class="select-default-option">${toSafeHTML(option.field)}</option>`;
|
|
148
153
|
}
|
|
149
154
|
let Type = getTypeCastFunction(option.type);
|
|
150
155
|
let optionValue = Type(option.value ?? option.field);
|
|
151
156
|
let elementValue = String(optionValue);
|
|
152
157
|
optionValues.set(elementValue, optionValue);
|
|
153
|
-
return `<option value="${elementValue}">${option.field}</option>`;
|
|
158
|
+
return `<option value="${toSafeHTML(elementValue)}">${toSafeHTML(option.field)}</option>`;
|
|
154
159
|
})
|
|
155
160
|
.join('');
|
|
156
161
|
this.inputElement.innerHTML = optionElements;
|
package/log-in-form.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import '/node_modules/capitalisk-auth-client/dist/capitalisk-log-in.js';
|
|
2
2
|
import { SocketConsumer } from './socket.js';
|
|
3
|
+
import { toSafeHTML } from './utils.js';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_AUTH_TIMEOUT = 10000;
|
|
5
6
|
|
|
@@ -45,11 +46,11 @@ class LogInForm extends SocketConsumer {
|
|
|
45
46
|
|
|
46
47
|
this.innerHTML = `
|
|
47
48
|
<capitalisk-log-in
|
|
48
|
-
hostname="${hostname}"
|
|
49
|
-
port="${port}"
|
|
50
|
-
network-symbol="${networkSymbol}"
|
|
51
|
-
chain-module-name="${chainModuleName}"
|
|
52
|
-
secure="${secure}"
|
|
49
|
+
hostname="${toSafeHTML(hostname)}"
|
|
50
|
+
port="${toSafeHTML(port)}"
|
|
51
|
+
network-symbol="${toSafeHTML(networkSymbol)}"
|
|
52
|
+
chain-module-name="${toSafeHTML(chainModuleName)}"
|
|
53
|
+
secure="${toSafeHTML(secure)}"
|
|
53
54
|
disabled="true"
|
|
54
55
|
></capitalisk-log-in>
|
|
55
56
|
`;
|
package/log-out.js
CHANGED
|
@@ -20,6 +20,16 @@ class LogOut extends SocketConsumer {
|
|
|
20
20
|
);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// A javascript: or data: URL would execute when assigned to location.href.
|
|
24
|
+
if (
|
|
25
|
+
providerLogOutURL.protocol !== 'https:' &&
|
|
26
|
+
providerLogOutURL.protocol !== 'http:'
|
|
27
|
+
) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'The logout-url attribute of log-out must be an http or https URL'
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
23
33
|
let clientId = this.getAttribute('client-id');
|
|
24
34
|
let postLogoutRedirectURI = this.getAttribute('post-logout-redirect-uri');
|
|
25
35
|
let searchParams = providerLogOutURL.searchParams;
|
package/model-input.js
CHANGED
|
@@ -215,16 +215,20 @@ class ModelInput extends SocketConsumer {
|
|
|
215
215
|
if (options != null) {
|
|
216
216
|
let fieldTypeValues = convertStringToFieldTypeValues(options, true, true);
|
|
217
217
|
optionValues = new Map();
|
|
218
|
+
// The options attribute can be written by a consumer rather than by hand,
|
|
219
|
+
// in which case it carries whatever a record held. Escaping is a no-op on
|
|
220
|
+
// the entities the template layer already produced, so a label keeps
|
|
221
|
+
// rendering as it did while markup in it stays text.
|
|
218
222
|
let optionElements = fieldTypeValues
|
|
219
223
|
.map((option) => {
|
|
220
224
|
if (option.field === placeholder) {
|
|
221
|
-
return `<option value="" selected class="select-default-option">${option.field}</option>`;
|
|
225
|
+
return `<option value="" selected class="select-default-option">${toSafeHTML(option.field)}</option>`;
|
|
222
226
|
}
|
|
223
227
|
let Type = getTypeCastFunction(option.type);
|
|
224
228
|
let optionValue = Type(option.value ?? option.field);
|
|
225
229
|
let elementValue = String(optionValue);
|
|
226
230
|
optionValues.set(elementValue, optionValue);
|
|
227
|
-
return `<option value="${elementValue}">${option.field}</option>`;
|
|
231
|
+
return `<option value="${toSafeHTML(elementValue)}">${toSafeHTML(option.field)}</option>`;
|
|
228
232
|
})
|
|
229
233
|
.join('');
|
|
230
234
|
this.inputElement.innerHTML = optionElements;
|
package/oauth-handler.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SocketConsumer } from './socket.js';
|
|
2
|
-
import { generateRandomHexString, convertStringToFieldParams } from './utils.js';
|
|
2
|
+
import { generateRandomHexString, convertStringToFieldParams, toSafeHTML } from './utils.js';
|
|
3
3
|
|
|
4
4
|
const DEFAULT_AUTH_TIMEOUT = 20000;
|
|
5
5
|
|
|
@@ -12,7 +12,25 @@ class OAuthHandler extends SocketConsumer {
|
|
|
12
12
|
if (successLocationHash) {
|
|
13
13
|
location.hash = successLocationHash;
|
|
14
14
|
} else if (successURL) {
|
|
15
|
-
|
|
15
|
+
let resolvedURL;
|
|
16
|
+
// Resolved against the document URL so relative paths behave as they did
|
|
17
|
+
// with the plain assignment; only an absolute URL brings its own scheme.
|
|
18
|
+
try {
|
|
19
|
+
resolvedURL = new URL(successURL, location.href);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'The success-url attribute of oauth-handler was not a valid URL'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
if (
|
|
26
|
+
resolvedURL.protocol !== 'https:' &&
|
|
27
|
+
resolvedURL.protocol !== 'http:'
|
|
28
|
+
) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
'The success-url attribute of oauth-handler must be an http or https URL'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
location.href = resolvedURL.href;
|
|
16
34
|
} else {
|
|
17
35
|
this.dispatchEvent(
|
|
18
36
|
new CustomEvent('navigate', {
|
|
@@ -42,7 +60,8 @@ class OAuthHandler extends SocketConsumer {
|
|
|
42
60
|
|
|
43
61
|
let useLocalStorage = !this.hasAttribute('use-session-storage');
|
|
44
62
|
let storageKey = this.getAttribute('state-storage-key') || 'oauth.state';
|
|
45
|
-
let
|
|
63
|
+
let storage = useLocalStorage ? localStorage : sessionStorage;
|
|
64
|
+
let expectedOAuthState = storage.getItem(storageKey);
|
|
46
65
|
if (!expectedOAuthState) {
|
|
47
66
|
this.innerHTML = `
|
|
48
67
|
<div class="error">OAuth authentication failed because the state was missing.</div>
|
|
@@ -83,11 +102,25 @@ class OAuthHandler extends SocketConsumer {
|
|
|
83
102
|
history.replaceState({}, document.title, location.pathname);
|
|
84
103
|
|
|
85
104
|
if (relevantStates.some(expectedState => expectedState === state)) {
|
|
105
|
+
// Single-use, so consumed before the exchange. Re-read rather than
|
|
106
|
+
// written back from the list captured above, which would drop a state
|
|
107
|
+
// another handler or tab added since.
|
|
108
|
+
let remainingStates = (storage.getItem(storageKey) || '')
|
|
109
|
+
.split(',')
|
|
110
|
+
.filter(existingState => existingState && existingState !== state);
|
|
111
|
+
if (remainingStates.length) {
|
|
112
|
+
storage.setItem(storageKey, remainingStates.join(','));
|
|
113
|
+
} else {
|
|
114
|
+
storage.removeItem(storageKey);
|
|
115
|
+
}
|
|
116
|
+
|
|
86
117
|
try {
|
|
87
118
|
await socket.invoke('log-in-oauth', { provider, data: { code, ...extraData } });
|
|
88
119
|
} catch (error) {
|
|
120
|
+
// The message is quoted back by the service and can carry parts of the
|
|
121
|
+
// query string which brought the browser here.
|
|
89
122
|
this.innerHTML = `
|
|
90
|
-
<div class="error">OAuth authentication failed. ${error.message}</div>
|
|
123
|
+
<div class="error">OAuth authentication failed. ${toSafeHTML(error.message)}</div>
|
|
91
124
|
`;
|
|
92
125
|
return;
|
|
93
126
|
}
|
package/oauth-link.js
CHANGED
|
@@ -11,7 +11,9 @@ class OAuthLink extends HTMLElement {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
this.handleClickEvent = () => {
|
|
14
|
-
|
|
14
|
+
// Re-asserts the stored state in case it was evicted; not regenerated,
|
|
15
|
+
// since the href was built from it at render time.
|
|
16
|
+
this.storeState();
|
|
15
17
|
};
|
|
16
18
|
}
|
|
17
19
|
|
|
@@ -73,33 +75,34 @@ class OAuthLink extends HTMLElement {
|
|
|
73
75
|
viewportNode.innerHTML = itemString;
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
// A fresh nonce per render; the handler consumes a state on use. Render only,
|
|
79
|
+
// since the href is rebuilt from the new state.
|
|
76
80
|
updateState() {
|
|
77
81
|
let provider = this.getAttribute('provider');
|
|
78
82
|
if (!provider) {
|
|
79
83
|
throw new Error('The provider attribute of oauth-link was not specified');
|
|
80
84
|
}
|
|
81
|
-
let useLocalStorage = !this.hasAttribute('use-session-storage');
|
|
82
85
|
let stateSize = Number(this.getAttribute('state-size') || 20);
|
|
86
|
+
this.provider = provider;
|
|
87
|
+
this.state = `${provider}-${generateRandomHexString(stateSize)}`;
|
|
88
|
+
this.storeState();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Re-read immediately before writing so another provider's state, from
|
|
92
|
+
// another link or tab, is not dropped.
|
|
93
|
+
storeState() {
|
|
94
|
+
if (!this.state) return;
|
|
95
|
+
let useLocalStorage = !this.hasAttribute('use-session-storage');
|
|
83
96
|
let storageKey = this.getAttribute('state-storage-key') || 'oauth.state';
|
|
84
|
-
|
|
85
97
|
let storage = useLocalStorage ? localStorage : sessionStorage;
|
|
86
98
|
let existingState = storage.getItem(storageKey);
|
|
87
|
-
let stateList;
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
stateList.pop();
|
|
93
|
-
}
|
|
94
|
-
} else {
|
|
95
|
-
stateList = []
|
|
96
|
-
}
|
|
97
|
-
if (this.provider !== provider) {
|
|
98
|
-
this.state = `${provider}-${generateRandomHexString(stateSize)}`;
|
|
99
|
-
this.provider = provider;
|
|
99
|
+
let stateList = existingState ? existingState.split(',').filter(Boolean) : [];
|
|
100
|
+
if (stateList.includes(this.state)) return;
|
|
101
|
+
let maxStateCount = Number(this.getAttribute('max-state-count') ?? 20);
|
|
102
|
+
if (stateList.length >= maxStateCount) {
|
|
103
|
+
stateList.pop();
|
|
100
104
|
}
|
|
101
105
|
stateList.unshift(this.state);
|
|
102
|
-
|
|
103
106
|
storage.setItem(storageKey, stateList.join(','));
|
|
104
107
|
}
|
|
105
108
|
|
package/package.json
CHANGED
package/utils.js
CHANGED
|
@@ -11,28 +11,61 @@ let backtickRegExp = /`/g;
|
|
|
11
11
|
let templateMultiStartBracesRegExp = /{{{*/g
|
|
12
12
|
let templateMultiEndBracesRegExp = /}}}*/g
|
|
13
13
|
|
|
14
|
+
// Expression brackets in their encoded form. Escaping leaves these alone so
|
|
15
|
+
// that it stays idempotent, but the browser turns them back into brackets when
|
|
16
|
+
// it parses them out of an attribute, which would hand a value to any component
|
|
17
|
+
// that re-evaluates that attribute (computable-value, computable-model-values).
|
|
18
|
+
// They are flattened here for the same reason the literal brackets above are.
|
|
19
|
+
let templateEncodedStartBracesRegExp = /(?:{){2,}/g
|
|
20
|
+
let templateEncodedEndBracesRegExp = /(?:}){2,}/g
|
|
21
|
+
|
|
22
|
+
// Line breaks are the one piece of markup a value is allowed to carry; they are
|
|
23
|
+
// escaped along with everything else and then put back, so that only a genuine
|
|
24
|
+
// break tag survives and a stray angle bracket elsewhere in the value cannot.
|
|
25
|
+
let safeBreakRegExp = /<(br ?\/?)>/g;
|
|
26
|
+
|
|
14
27
|
export function toSafeString(value) {
|
|
15
28
|
return value.replace(expressionCharsRegExp, '$!{$1}')
|
|
16
29
|
.replace(backtickRegExp, `'`)
|
|
17
30
|
.replace(templateMultiStartBracesRegExp, '[[')
|
|
18
|
-
.replace(templateMultiEndBracesRegExp, ']]')
|
|
31
|
+
.replace(templateMultiEndBracesRegExp, ']]')
|
|
32
|
+
.replace(templateEncodedStartBracesRegExp, '[[')
|
|
33
|
+
.replace(templateEncodedEndBracesRegExp, ']]');
|
|
19
34
|
}
|
|
20
35
|
|
|
21
36
|
export function toSafeHTML(value) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return '[invalid]';
|
|
37
|
+
// null and undefined are passed through so that callers can tell a missing
|
|
38
|
+
// value apart from an empty one and fall back to their own default.
|
|
39
|
+
if (value == null) return value;
|
|
40
|
+
if (typeof value !== 'string') {
|
|
41
|
+
if (typeof value.toString !== 'function') {
|
|
42
|
+
return '[invalid]';
|
|
43
|
+
}
|
|
44
|
+
// Anything which is not a string still has to be escaped; a field holding a
|
|
45
|
+
// list or an object would otherwise reach the page as markup once it was
|
|
46
|
+
// stringified further down the line.
|
|
47
|
+
value = String(value);
|
|
34
48
|
}
|
|
35
|
-
return value
|
|
49
|
+
return toSafeString(value)
|
|
50
|
+
// Entities which this function itself produces are left alone so that
|
|
51
|
+
// escaping an already-escaped value is a no-op. Consumer chains pass values
|
|
52
|
+
// through several templates and would otherwise encode them once per hop.
|
|
53
|
+
.replace(/&(?!(amp|lt|gt|quot|#039|#123|#125);)/g, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(safeBreakRegExp, '<$1>')
|
|
57
|
+
.replace(/"/g, '"')
|
|
58
|
+
.replace(/'/g, ''')
|
|
59
|
+
.replace(/\{/g, '{')
|
|
60
|
+
.replace(/\}/g, '}')
|
|
61
|
+
.replace(/\n/g, '<br>')
|
|
62
|
+
// The escaping above re-creates the brackets which toSafeString flattened,
|
|
63
|
+
// so the encoded form is flattened again here, once nothing further will
|
|
64
|
+
// produce it. A value such as {{expression}} would otherwise
|
|
65
|
+
// leave as two adjacent encoded brackets which the browser decodes back
|
|
66
|
+
// into a live expression for whichever component next evaluates it.
|
|
67
|
+
.replace(templateEncodedStartBracesRegExp, '[[')
|
|
68
|
+
.replace(templateEncodedEndBracesRegExp, ']]');
|
|
36
69
|
}
|
|
37
70
|
|
|
38
71
|
export function toExpression(value) {
|
|
@@ -307,7 +340,13 @@ function replaceExpressions(string, replaceFn) {
|
|
|
307
340
|
let match = matches[i];
|
|
308
341
|
charList.splice(match.startIndex, match.length, ...charSubs[i]);
|
|
309
342
|
}
|
|
310
|
-
|
|
343
|
+
// Adjacent brackets can also form where two separately escaped values meet,
|
|
344
|
+
// which no single-value pass can see, so the joined result is flattened too.
|
|
345
|
+
// Only the encoded form is touched; expressions written by the page author
|
|
346
|
+
// stay literal and remain available to nested components to evaluate later.
|
|
347
|
+
return charList.join('')
|
|
348
|
+
.replace(templateEncodedStartBracesRegExp, '[[')
|
|
349
|
+
.replace(templateEncodedEndBracesRegExp, ']]');
|
|
311
350
|
}
|
|
312
351
|
|
|
313
352
|
export function renderTemplate(templateString, data, socket, autoExecFunction) {
|
|
@@ -363,6 +402,12 @@ export function updateConsumerElements(consumers, value, template, sourceElement
|
|
|
363
402
|
})
|
|
364
403
|
.filter(([ selector, attributeName ]) => selector);
|
|
365
404
|
|
|
405
|
+
// A template is the supported way to send markup to a consumer: the template
|
|
406
|
+
// itself is written by the page author and is left intact, while the value
|
|
407
|
+
// substituted into it is escaped by the {{ }} expression. Without a template
|
|
408
|
+
// there is no author-written markup to preserve, so the value is escaped
|
|
409
|
+
// before it can reach the innerHTML of a consumer further down.
|
|
410
|
+
let isTemplateApplied = !!template;
|
|
366
411
|
if (template) {
|
|
367
412
|
value = renderTemplate.call(this, template, { value }, null, true);
|
|
368
413
|
}
|
|
@@ -412,7 +457,7 @@ export function updateConsumerElements(consumers, value, template, sourceElement
|
|
|
412
457
|
sourceElementName ? `${sourceElementName}:${value}` : value
|
|
413
458
|
);
|
|
414
459
|
} else {
|
|
415
|
-
element.innerHTML = value;
|
|
460
|
+
element.innerHTML = isTemplateApplied ? value : toSafeHTML(value);
|
|
416
461
|
}
|
|
417
462
|
}
|
|
418
463
|
}
|