@rancher/shell 3.0.12-rc.5 → 3.0.12-rc.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/apis/intf/resources-api/resource-base.ts +42 -2
  2. package/apis/intf/resources-api/resource-instance.ts +101 -0
  3. package/apis/intf/resources-api/resources-api.ts +177 -23
  4. package/apis/intf/resources.ts +8 -6
  5. package/apis/resources/__tests__/resources-api-class.test.ts +309 -28
  6. package/apis/resources/resources-api-class.ts +232 -43
  7. package/assets/images/providers/traefik.png +0 -0
  8. package/assets/translations/en-us.yaml +10 -4
  9. package/chart/__tests__/rancher-monitoring-dashboards-index.test.ts +269 -0
  10. package/chart/rancher-monitoring-dashboards/index.vue +155 -0
  11. package/cloud-credential/__tests__/generic.test.ts +132 -0
  12. package/cloud-credential/generic.vue +33 -3
  13. package/components/EtcdInfoBanner.vue +2 -12
  14. package/components/GrafanaDashboard.vue +2 -8
  15. package/components/Resource/Detail/Masthead/__tests__/index.test.ts +68 -14
  16. package/components/Resource/Detail/Masthead/index.vue +8 -3
  17. package/components/form/Security.vue +1 -1
  18. package/components/formatter/Translate.vue +22 -1
  19. package/components/nav/Group.vue +5 -10
  20. package/components/nav/TopLevelMenu.vue +1 -30
  21. package/components/nav/__tests__/Group.test.ts +61 -0
  22. package/config/labels-annotations.js +1 -0
  23. package/config/table-headers.js +5 -2
  24. package/core/__tests__/plugin-products-apply.test.ts +787 -0
  25. package/core/__tests__/plugin-products-extend.test.ts +189 -0
  26. package/core/__tests__/plugin-products-helpers.test.ts +81 -78
  27. package/core/__tests__/plugin-products-new.test.ts +389 -0
  28. package/core/__tests__/plugin-products-scenarios.test.ts +980 -0
  29. package/core/__tests__/plugin-products-side-menu.test.ts +1935 -0
  30. package/core/plugin-products-base.ts +201 -93
  31. package/core/plugin-products-extending.ts +1 -1
  32. package/core/plugin-products-external.ts +380 -0
  33. package/core/plugin-products-helpers.ts +28 -24
  34. package/core/plugin-products-internal.ts +207 -0
  35. package/core/plugin-products-top-level.ts +10 -5
  36. package/core/plugin-products-type-guards.ts +17 -6
  37. package/core/plugin-products.ts +5 -7
  38. package/core/plugin-types.ts +1 -389
  39. package/core/plugin.ts +10 -11
  40. package/core/types.ts +6 -202
  41. package/detail/__tests__/provisioning.cattle.io.cluster.test.ts +52 -0
  42. package/detail/pod.vue +1 -1
  43. package/detail/provisioning.cattle.io.cluster.vue +0 -2
  44. package/edit/auth/AuthProviderWarningBanners.vue +12 -0
  45. package/edit/auth/__tests__/AuthProviderWarningBanners.test.ts +10 -1
  46. package/edit/auth/__tests__/azuread.test.ts +1 -0
  47. package/edit/auth/__tests__/github.test.ts +1 -0
  48. package/edit/auth/__tests__/oidc.test.ts +1 -0
  49. package/edit/auth/__tests__/saml.test.ts +1 -0
  50. package/edit/auth/ldap/__tests__/index.test.ts +1 -0
  51. package/edit/provisioning.cattle.io.cluster/index.vue +1 -1
  52. package/edit/provisioning.cattle.io.cluster/rke2.vue +1 -1
  53. package/edit/provisioning.cattle.io.cluster/tabs/networking/index.vue +2 -2
  54. package/list/management.cattle.io.user.vue +24 -5
  55. package/list/utils/management.cattle.io.cluster.utils.ts +1 -1
  56. package/machine-config/__tests__/generic.test.ts +163 -0
  57. package/machine-config/components/EC2Networking.vue +31 -7
  58. package/machine-config/components/__tests__/EC2Networking.test.ts +38 -15
  59. package/machine-config/generic.vue +32 -5
  60. package/mixins/resource-fetch.js +1 -1
  61. package/models/__tests__/rke-machine.cattle.io.vmwarevspheremachinetemplate.test.ts +22 -0
  62. package/models/pod.js +12 -10
  63. package/models/rke-machine.cattle.io.vmwarevspheremachinetemplate.js +9 -0
  64. package/package.json +1 -1
  65. package/pages/auth/login.vue +1 -1
  66. package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +110 -0
  67. package/pages/c/_cluster/apps/charts/install.vue +29 -0
  68. package/pages/c/_cluster/auth/config/index.vue +11 -4
  69. package/pages/c/_cluster/monitoring/index.vue +6 -1
  70. package/plugins/dashboard-store/resource-class.js +10 -3
  71. package/plugins/steve/__tests__/steve-class-resource-api.test.ts +147 -0
  72. package/plugins/steve/steve-class.js +43 -0
  73. package/plugins/steve/subscribe.js +11 -0
  74. package/rancher-components/Form/LabeledInput/LabeledInput.test.ts +115 -0
  75. package/rancher-components/Form/LabeledInput/LabeledInput.vue +83 -3
  76. package/store/__tests__/auth.test.ts +18 -0
  77. package/store/__tests__/notifications.test.ts +466 -1
  78. package/store/__tests__/ui-context.test.ts +255 -0
  79. package/store/auth.js +11 -1
  80. package/store/features.js +1 -0
  81. package/store/plugins.js +6 -0
  82. package/types/rancher/steve.api.ts +2 -2
  83. package/types/shell/index.d.ts +58 -0
  84. package/types/store/dashboard-store.types.ts +2 -2
  85. package/types/store/type-map.ts +262 -1
  86. package/utils/async.ts +2 -0
  87. package/utils/grafana.js +2 -17
  88. package/utils/monitoring.js +38 -1
  89. package/core/__tests__/plugin-products.test.ts +0 -4694
@@ -6,6 +6,7 @@ import { MODE, _EDIT } from '@shell/config/query-params';
6
6
  import { authProvidersInfo } from '@shell/utils/auth';
7
7
  import { Banner } from '@components/Banner';
8
8
  import Loading from '@shell/components/Loading';
9
+ import { HIDE_LOCAL_AUTH_PROVIDER } from '@shell/store/features';
9
10
 
10
11
  const resource = MANAGEMENT.AUTH_CONFIG;
11
12
 
@@ -28,8 +29,9 @@ export default {
28
29
  data() {
29
30
  return {
30
31
  // Provided by fetch later
31
- enabled: false,
32
- nonLocal: null,
32
+ enabled: false,
33
+ nonLocal: null,
34
+ disableLocalAuth: this.$store.getters['features/get'](HIDE_LOCAL_AUTH_PROVIDER)
33
35
  };
34
36
  },
35
37
 
@@ -100,8 +102,13 @@ export default {
100
102
  <router-link :to="localUsersRoute">
101
103
  {{ t('authConfig.manageLocal') }}
102
104
  </router-link>
103
- <br>
104
- {{ t('authConfig.noneEnabled') }}
105
+ <br><br>
106
+ <template v-if="disableLocalAuth">
107
+ {{ t('authConfig.bannerEnableAuthProvider', null, true) }}
108
+ </template>
109
+ <template v-else>
110
+ {{ t('authConfig.noneEnabled') }}
111
+ </template>
105
112
  </div>
106
113
  </Banner>
107
114
  <SelectIconGrid
@@ -106,7 +106,12 @@ export default {
106
106
  const grafanaMatch = findBy(externalLinks, 'group', 'grafana');
107
107
  // Generate Grafana link
108
108
  const currentCluster = this.$store.getters['currentCluster'];
109
- const rancherMonitoring = !isEmpty(res.apps) ? findBy(res.apps, 'id', 'cattle-monitoring-system/rancher-monitoring') : '';
109
+ let rancherMonitoring = !isEmpty(res.apps) ? findBy(res.apps, 'id', 'cattle-monitoring-system/rancher-monitoring-dashboards') : '';
110
+
111
+ if (!rancherMonitoring) {
112
+ rancherMonitoring = !isEmpty(res.apps) ? findBy(res.apps, 'id', 'cattle-monitoring-system/rancher-monitoring') : '';
113
+ }
114
+
110
115
  const clusterPrefix = getClusterPrefix(rancherMonitoring?.currentVersion || '', currentCluster.id);
111
116
 
112
117
  grafanaMatch.link = `${ clusterPrefix }/api/v1/namespaces/cattle-monitoring-system/services/http:rancher-monitoring-grafana:80/proxy/`;
@@ -1253,8 +1253,11 @@ export default class Resource {
1253
1253
 
1254
1254
  async _save(opt = { }) {
1255
1255
  const forNew = !this.id;
1256
+ let errors;
1256
1257
 
1257
- const errors = this.validationErrors(this, opt);
1258
+ if (!opt.skipUIValidation) {
1259
+ errors = this.validationErrors(this, opt);
1260
+ }
1258
1261
 
1259
1262
  if (!isEmpty(errors)) {
1260
1263
  return Promise.reject(errors);
@@ -1294,7 +1297,9 @@ export default class Resource {
1294
1297
  // @TODO remove this once the API maps steve _type <-> k8s type in both directions
1295
1298
  // `JSON.parse(JSON.stringify` - Completely disconnect the object we're going to send and `this`. This ensures that properties
1296
1299
  // removed from opt.data before sending (as part of cleanForSave) are not stripped from where they're still needed (`this`)
1297
- opt.data = this.toSave() || JSON.parse(JSON.stringify(this));
1300
+ if (!(opt.method === 'patch')) {
1301
+ opt.data = this.toSave() || JSON.parse(JSON.stringify(this));
1302
+ }
1298
1303
 
1299
1304
  if (opt.data._type) {
1300
1305
  opt.data.type = opt.data._type;
@@ -1312,7 +1317,9 @@ export default class Resource {
1312
1317
  opt.data.annotations = opt.data._annotations;
1313
1318
  }
1314
1319
 
1315
- opt.data = this.cleanForSave(opt.data, forNew);
1320
+ if (!(opt.method === 'patch')) {
1321
+ opt.data = this.cleanForSave(opt.data, forNew);
1322
+ }
1316
1323
 
1317
1324
  // handle "replace" opt as a query param _replace=true for norman PUT requests
1318
1325
  if (opt?.replace && opt.method === 'put') {
@@ -0,0 +1,147 @@
1
+ import {
2
+ describe, it, expect, jest, beforeEach, afterEach
3
+ } from '@jest/globals';
4
+ import Steve from '@shell/plugins/steve/steve-class.js';
5
+
6
+ describe('class: Steve — ResourceInstanceApi methods', () => {
7
+ let consoleErrorSpy: any;
8
+
9
+ beforeEach(() => {
10
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
11
+ });
12
+
13
+ afterEach(() => {
14
+ consoleErrorSpy.mockRestore();
15
+ });
16
+
17
+ function createSteveModel(overrides: Record<string, any> = {}) {
18
+ const dispatch = jest.fn<any, any[]>();
19
+
20
+ dispatch.mockResolvedValue(undefined);
21
+
22
+ // @ts-expect-error - JS class without TS declarations
23
+ const model = new Steve({
24
+ type: 'configmap',
25
+ id: 'default/my-config',
26
+ metadata: { name: 'my-config', namespace: 'default' },
27
+ links: { update: 'https://rancher/v1/configmaps/default/my-config', self: 'https://rancher/v1/configmaps/default/my-config' },
28
+ ...overrides,
29
+ }, {
30
+ getters: {
31
+ schemaFor: () => ({ linkFor: jest.fn() }),
32
+ keyFieldForType: () => 'id',
33
+ },
34
+ dispatch,
35
+ rootGetters: {
36
+ 'i18n/t': jest.fn(),
37
+ 'type-map/optionsFor': () => ({
38
+ isEditable: true, isRemovable: true, isCreatable: true
39
+ }),
40
+ 'type-map/hasCustomEdit': () => true,
41
+ },
42
+ });
43
+
44
+ return { model: model as any, dispatch };
45
+ }
46
+
47
+ describe('update', () => {
48
+ it('should send a merge-patch request and load the response into the store', async() => {
49
+ const patchResponse = {
50
+ type: 'configmap', id: 'default/my-config', kind: 'ConfigMap', data: { key: 'patched' }
51
+ };
52
+ const { model, dispatch } = createSteveModel();
53
+
54
+ dispatch
55
+ .mockResolvedValueOnce(patchResponse)
56
+ .mockResolvedValueOnce(undefined);
57
+
58
+ const result = await model.update({ data: { key: 'patched' } });
59
+
60
+ expect(result).toStrictEqual(model);
61
+ expect(dispatch).toHaveBeenCalledWith('request', {
62
+ opt: expect.objectContaining({
63
+ url: 'https://rancher/v1/configmaps/default/my-config',
64
+ method: 'patch',
65
+ headers: expect.objectContaining({ 'content-type': 'application/strategic-merge-patch+json' }),
66
+ data: { data: { key: 'patched' } },
67
+ }),
68
+ type: 'configmap'
69
+ });
70
+ expect(dispatch).toHaveBeenCalledWith('load', expect.objectContaining({
71
+ data: patchResponse,
72
+ invalidatePageCache: false,
73
+ }));
74
+ });
75
+
76
+ it('should not call load when response is a Table', async() => {
77
+ const tableResponse = { kind: 'Table', rows: [] };
78
+ const { model, dispatch } = createSteveModel();
79
+
80
+ dispatch.mockResolvedValueOnce(tableResponse);
81
+
82
+ await model.update({ data: { key: 'value' } });
83
+
84
+ expect(dispatch).toHaveBeenCalledTimes(1);
85
+ expect(dispatch).not.toHaveBeenCalledWith('load', expect.anything());
86
+ });
87
+
88
+ it('should throw when canEdit is false', async() => {
89
+ const { model, dispatch } = createSteveModel({ links: {} });
90
+
91
+ await expect(model.update({ data: {} })).rejects.toThrow(
92
+ 'ResourceInstance API error - configmap/default/my-config - Cannot patch: permission denied'
93
+ );
94
+ expect(dispatch).not.toHaveBeenCalled();
95
+ });
96
+ });
97
+
98
+ describe('replace', () => {
99
+ it('should call save() and return the instance', async() => {
100
+ const { model, dispatch } = createSteveModel();
101
+
102
+ dispatch.mockResolvedValueOnce(undefined);
103
+
104
+ const result = await model.replace();
105
+
106
+ expect(result).toStrictEqual(model);
107
+ expect(dispatch).toHaveBeenCalledWith('request', expect.objectContaining({ opt: expect.objectContaining({ method: 'put' }) }));
108
+ });
109
+
110
+ it('should throw when canEdit is false', async() => {
111
+ const { model, dispatch } = createSteveModel({ links: {} });
112
+
113
+ await expect(model.replace()).rejects.toThrow(
114
+ 'ResourceInstance API error - configmap/default/my-config - Cannot update: permission denied'
115
+ );
116
+ expect(dispatch).not.toHaveBeenCalled();
117
+ });
118
+ });
119
+
120
+ describe('delete', () => {
121
+ it('should call remove()', async() => {
122
+ const { model, dispatch } = createSteveModel({
123
+ links: {
124
+ update: 'https://rancher/v1/configmaps/default/my-config',
125
+ self: 'https://rancher/v1/configmaps/default/my-config',
126
+ remove: 'https://rancher/v1/configmaps/default/my-config',
127
+ }
128
+ });
129
+
130
+ dispatch.mockResolvedValueOnce({ _status: 204 });
131
+ dispatch.mockResolvedValueOnce(undefined);
132
+
133
+ await model.delete();
134
+
135
+ expect(dispatch).toHaveBeenCalledWith('request', expect.objectContaining({ opt: expect.objectContaining({ method: 'delete' }) }));
136
+ });
137
+
138
+ it('should throw when canDelete is false', async() => {
139
+ const { model, dispatch } = createSteveModel({ links: {} });
140
+
141
+ await expect(model.delete()).rejects.toThrow(
142
+ 'ResourceInstance API error - configmap/default/my-config - Cannot delete: permission denied'
143
+ );
144
+ expect(dispatch).not.toHaveBeenCalled();
145
+ });
146
+ });
147
+ });
@@ -91,4 +91,47 @@ export default class SteveModel extends HybridModel {
91
91
  }
92
92
  }
93
93
  }
94
+
95
+ /**
96
+ * RESOURCES API - ResourceInstance update method to send a PATCH request
97
+ */
98
+ async update(data) {
99
+ if (!this.canEdit) {
100
+ throw new Error(`ResourceInstance API error - ${ this.type }/${ this.id } - Cannot patch: permission denied`);
101
+ }
102
+
103
+ console.error('Updating instance with data:', data); // eslint-disable-line no-console
104
+
105
+ await this.save({
106
+ data,
107
+ method: 'patch',
108
+ headers: { 'content-type': 'application/strategic-merge-patch+json' }
109
+ });
110
+
111
+ return this;
112
+ }
113
+
114
+ /**
115
+ * RESOURCES API - ResourceInstance update method to send a PUT request
116
+ */
117
+ async replace() {
118
+ if (!this.canEdit) {
119
+ throw new Error(`ResourceInstance API error - ${ this.type }/${ this.id } - Cannot update: permission denied`);
120
+ }
121
+
122
+ await this.save();
123
+
124
+ return this;
125
+ }
126
+
127
+ /**
128
+ * RESOURCES API - ResourceInstance delete method to send a DELETE request
129
+ */
130
+ async delete() {
131
+ if (!this.canDelete) {
132
+ throw new Error(`ResourceInstance API error - ${ this.type }/${ this.id } - Cannot delete: permission denied`);
133
+ }
134
+
135
+ await this.remove();
136
+ }
94
137
  }
@@ -834,6 +834,17 @@ const defaultActions = {
834
834
  state.debugSocket && console.debug(`Subscribe Flush [${ getters.storeName }]`, queue.length, 'items'); // eslint-disable-line no-console
835
835
 
836
836
  for ( const { action, event, body } of queue ) {
837
+ const havePage = getters['havePage'](body?.type);
838
+
839
+ if (havePage) {
840
+ // eslint-disable-next-line no-console
841
+ console.warn(`Prevented watch \`flush\` data from polluting the cache for type "${ body?.type }" (currently represents a page)`, {
842
+ action, event, body
843
+ });
844
+
845
+ continue;
846
+ }
847
+
837
848
  if ( action === 'dispatch' && event === 'load' ) {
838
849
  // Group loads into one loadMulti when possible
839
850
  toLoad.push(body);
@@ -57,6 +57,121 @@ describe('component: LabeledInput', () => {
57
57
  });
58
58
  });
59
59
 
60
+ describe('type "integer"', () => {
61
+ const i18nMock = { $store: { getters: { 'i18n/t': jest.fn() } } };
62
+
63
+ it('should render a text input with numeric inputmode', () => {
64
+ const wrapper = mount(LabeledInput, {
65
+ propsData: { type: 'integer', value: '' },
66
+ mocks: i18nMock
67
+ });
68
+ const input = wrapper.find('input');
69
+
70
+ expect(input.attributes('type')).toBe('text');
71
+ expect(input.attributes('inputmode')).toBe('numeric');
72
+ });
73
+
74
+ it.each([
75
+ 'e', 'E', '.', '+',
76
+ ])('should prevent non-integer key "%s"', (key) => {
77
+ const wrapper = mount(LabeledInput, {
78
+ propsData: { type: 'integer', value: '' },
79
+ mocks: i18nMock
80
+ });
81
+ const input = wrapper.find('input');
82
+ const event = new KeyboardEvent('keydown', { key, cancelable: true });
83
+
84
+ input.element.dispatchEvent(event);
85
+
86
+ expect(event.defaultPrevented).toBe(true);
87
+ });
88
+
89
+ it.each([
90
+ '0', '1', '9', '-', 'Backspace', 'Tab', 'ArrowLeft',
91
+ ])('should allow key "%s"', (key) => {
92
+ const wrapper = mount(LabeledInput, {
93
+ propsData: { type: 'integer', value: '' },
94
+ mocks: i18nMock
95
+ });
96
+ const input = wrapper.find('input');
97
+ const event = new KeyboardEvent('keydown', { key, cancelable: true });
98
+
99
+ input.element.dispatchEvent(event);
100
+
101
+ expect(event.defaultPrevented).toBe(false);
102
+ });
103
+
104
+ it('should also block "-" when min="0"', () => {
105
+ const wrapper = mount(LabeledInput, {
106
+ propsData: { type: 'integer', value: '' },
107
+ attrs: { min: '0' },
108
+ mocks: i18nMock
109
+ });
110
+ const input = wrapper.find('input');
111
+ const event = new KeyboardEvent('keydown', { key: '-', cancelable: true });
112
+
113
+ input.element.dispatchEvent(event);
114
+
115
+ expect(event.defaultPrevented).toBe(true);
116
+ });
117
+
118
+ function createPasteEvent(text: string): Event {
119
+ const event = new Event('paste', { cancelable: true });
120
+
121
+ (event as Event & { clipboardData: Pick<DataTransfer, 'getData'> }).clipboardData = { getData: () => text };
122
+
123
+ return event;
124
+ }
125
+
126
+ it.each([
127
+ '123',
128
+ '-5',
129
+ ])('should allow pasting valid integer "%s"', (text) => {
130
+ const wrapper = mount(LabeledInput, {
131
+ propsData: { type: 'integer', value: '' },
132
+ mocks: i18nMock
133
+ });
134
+ const input = wrapper.find('input');
135
+ const event = createPasteEvent(text);
136
+
137
+ input.element.dispatchEvent(event);
138
+
139
+ expect(event.defaultPrevented).toBe(false);
140
+ });
141
+
142
+ it.each([
143
+ '-e10',
144
+ '1.5',
145
+ '12abc',
146
+ '1e5',
147
+ ])('should block pasting invalid integer "%s"', (text) => {
148
+ const wrapper = mount(LabeledInput, {
149
+ propsData: { type: 'integer', value: '' },
150
+ mocks: i18nMock
151
+ });
152
+ const input = wrapper.find('input');
153
+ const event = createPasteEvent(text);
154
+
155
+ input.element.dispatchEvent(event);
156
+
157
+ expect(event.defaultPrevented).toBe(true);
158
+ });
159
+
160
+ it('should block pasting negative integer when min="0"', () => {
161
+ const wrapper = mount(LabeledInput, {
162
+ propsData: { type: 'integer', value: '' },
163
+ attrs: { min: '0' },
164
+ mocks: i18nMock
165
+ });
166
+ const input = wrapper.find('input');
167
+ const event = createPasteEvent('-5');
168
+
169
+ input.element.dispatchEvent(event);
170
+
171
+ expect(event.defaultPrevented).toBe(true);
172
+ });
173
+ });
174
+
60
175
  describe('a11y: adding ARIA props', () => {
61
176
  const ariaLabelVal = 'some-aria-label';
62
177
  const subLabelVal = 'some-sublabel';
@@ -29,7 +29,11 @@ export default defineComponent({
29
29
  ...labeledFormElementProps,
30
30
  /**
31
31
  * The type of the Labeled Input.
32
- * @values text, cron, multiline, multiline-password
32
+ *
33
+ * Any native HTML input type is passed through to the underlying input
34
+ * (e.g. text, password, number, email). A few custom values change the
35
+ * rendering or behaviour:
36
+ * @values cron (renders as text), multiline, multiline-password, integer (renders as text with inputmode="numeric", blocks non-integer input)
33
37
  */
34
38
  type: {
35
39
  type: String,
@@ -150,6 +154,23 @@ export default defineComponent({
150
154
  });
151
155
 
152
156
  const effectiveStatus = computed(() => props.status);
157
+ const isInteger = computed(() => props.type === 'integer');
158
+ const nativeType = computed(() => {
159
+ if (props.type === 'cron') {
160
+ return 'text';
161
+ }
162
+
163
+ if (props.type === 'integer') {
164
+ return 'text';
165
+ }
166
+
167
+ return props.type;
168
+ });
169
+
170
+ // Hints the mobile keyboard to show a numeric layout, since the
171
+ // integer type renders as type="text" to avoid browser number
172
+ // formatting quirks (e.g. scientific notation for large values).
173
+ const inputMode = computed(() => (isInteger.value ? 'numeric' : undefined));
153
174
 
154
175
  return {
155
176
  focused,
@@ -163,6 +184,9 @@ export default defineComponent({
163
184
  veeHandleBlur,
164
185
  veeValidate,
165
186
  effectiveStatus,
187
+ isInteger,
188
+ nativeType,
189
+ inputMode,
166
190
  };
167
191
  },
168
192
 
@@ -325,6 +349,59 @@ export default defineComponent({
325
349
  }
326
350
  },
327
351
 
352
+ isValidIntegerInput(inputValue: string): boolean {
353
+ const integer = /^-?[0-9]*$/;
354
+ const nonNegativeInteger = /^[0-9]*$/;
355
+ const pattern = Number(this.$attrs.min) >= 0 ? nonNegativeInteger : integer;
356
+
357
+ if (!pattern.test(inputValue)) {
358
+ return false;
359
+ }
360
+
361
+ const numeric = Number(inputValue);
362
+
363
+ // Safe integer is primarily here to validate that the number fits in the datatype
364
+ // used for numbers in the browser, if we exceed this safe number the browser automatically converts the number to scientific notation.
365
+ if (inputValue !== '' && inputValue !== '-' && !Number.isSafeInteger(numeric)) {
366
+ return false;
367
+ }
368
+
369
+ return true;
370
+ },
371
+
372
+ prospectiveValue(input: HTMLInputElement, inserted: string): string {
373
+ const start = input.selectionStart ?? 0;
374
+ const end = input.selectionEnd ?? 0;
375
+
376
+ return input.value.slice(0, start) + inserted + input.value.slice(end);
377
+ },
378
+
379
+ onKeydown(event: KeyboardEvent): void {
380
+ // Skip named keys (Backspace, Arrow, etc.) and modifier shortcuts (Ctrl+A, Cmd+C).
381
+ if (!this.isInteger || event.key.length !== 1 || event.ctrlKey || event.metaKey) {
382
+ return;
383
+ }
384
+
385
+ const next = this.prospectiveValue(event.target as HTMLInputElement, event.key);
386
+
387
+ if (!this.isValidIntegerInput(next)) {
388
+ event.preventDefault();
389
+ }
390
+ },
391
+
392
+ onPaste(event: ClipboardEvent): void {
393
+ if (!this.isInteger) {
394
+ return;
395
+ }
396
+
397
+ const paste = event.clipboardData?.getData('text/plain') ?? '';
398
+ const next = this.prospectiveValue(event.target as HTMLInputElement, paste);
399
+
400
+ if (!this.isValidIntegerInput(next)) {
401
+ event.preventDefault();
402
+ }
403
+ },
404
+
328
405
  /**
329
406
  * Emit on input change
330
407
  */
@@ -435,14 +512,15 @@ export default defineComponent({
435
512
  :id="inputId"
436
513
  ref="value"
437
514
  v-stripped-aria-label="!hasLabel && ariaLabel ? ariaLabel : undefined"
438
- :role="type === 'number' ? undefined : 'textbox'"
515
+ :role="nativeType === 'number' ? undefined : 'textbox'"
439
516
  :class="{ 'no-label': !hasLabel }"
440
517
  v-bind="$attrs"
441
518
  :name="name || undefined"
442
519
  :maxlength="_maxlength"
443
520
  :disabled="isDisabled"
444
521
  :aria-disabled="isDisabled"
445
- :type="type === 'cron' ? 'text' : type"
522
+ :type="nativeType"
523
+ :inputmode="inputMode"
446
524
  :value="value"
447
525
  :placeholder="_placeholder"
448
526
  autocomplete="off"
@@ -451,6 +529,8 @@ export default defineComponent({
451
529
  :aria-describedby="ariaDescribedBy"
452
530
  :aria-required="requiredField"
453
531
  @input="onInput"
532
+ @keydown="onKeydown"
533
+ @paste="onPaste"
454
534
  @focus="onFocus"
455
535
  @blur="onBlur"
456
536
  @change="onChange"
@@ -134,3 +134,21 @@ describe('action: test', () => {
134
134
  });
135
135
  });
136
136
  });
137
+
138
+ describe('action: getLocalProviderEnabled', () => {
139
+ it('should return true if local auth provider exists', async() => {
140
+ const dispatch = jest.fn().mockResolvedValue({ id: 'local' });
141
+ const result = await actions.getLocalProviderEnabled({ dispatch } as any);
142
+
143
+ expect(dispatch).toHaveBeenCalledWith('getAuthProvider', 'local');
144
+ expect(result).toBe(true);
145
+ });
146
+
147
+ it('should return false if local auth provider does not exist', async() => {
148
+ const dispatch = jest.fn().mockResolvedValue(undefined);
149
+ const result = await actions.getLocalProviderEnabled({ dispatch } as any);
150
+
151
+ expect(dispatch).toHaveBeenCalledWith('getAuthProvider', 'local');
152
+ expect(result).toBe(false);
153
+ });
154
+ });