@rancher/shell 3.0.9 → 3.0.11

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 (104) hide show
  1. package/assets/styles/base/_color.scss +4 -0
  2. package/assets/styles/themes/_light.scss +6 -6
  3. package/assets/styles/themes/_modern.scss +14 -6
  4. package/assets/translations/en-us.yaml +9 -10
  5. package/chart/__tests__/rancher-backup-index.test.ts +248 -0
  6. package/chart/rancher-backup/index.vue +41 -2
  7. package/components/BrandImage.vue +6 -5
  8. package/components/ConsumptionGauge.vue +12 -4
  9. package/components/CopyToClipboard.vue +28 -0
  10. package/components/CopyToClipboardText.vue +4 -0
  11. package/components/CruResource.vue +1 -0
  12. package/components/DynamicContent/DynamicContentIcon.vue +3 -2
  13. package/components/ExplorerProjectsNamespaces.vue +1 -4
  14. package/components/GlobalRoleBindings.vue +1 -5
  15. package/components/LazyImage.vue +2 -1
  16. package/components/Resource/Detail/Card/Scaler.vue +4 -4
  17. package/components/ResourceDetail/index.vue +0 -21
  18. package/components/Tabbed/index.vue +6 -0
  19. package/components/__tests__/ConsumptionGauge.test.ts +31 -0
  20. package/components/__tests__/CruResource.test.ts +35 -1
  21. package/components/form/ProjectMemberEditor.vue +0 -10
  22. package/components/nav/TopLevelMenu.helper.ts +7 -79
  23. package/components/nav/__tests__/TopLevelMenu.helper.test.ts +2 -53
  24. package/composables/useIsNewDetailPageEnabled.test.ts +98 -0
  25. package/composables/useIsNewDetailPageEnabled.ts +12 -0
  26. package/config/private-label.js +2 -1
  27. package/config/product/apps.js +1 -0
  28. package/config/product/explorer.js +11 -1
  29. package/config/table-headers.js +0 -9
  30. package/config/types.js +0 -1
  31. package/core/__tests__/extension-manager-impl.test.js +187 -2
  32. package/core/extension-manager-impl.js +4 -2
  33. package/core/plugin-helpers.ts +31 -0
  34. package/detail/__tests__/node.test.ts +83 -0
  35. package/detail/management.cattle.io.oidcclient.vue +2 -1
  36. package/detail/node.vue +1 -0
  37. package/edit/auth/github-app-steps.vue +2 -0
  38. package/edit/auth/github-steps.vue +2 -0
  39. package/edit/catalog.cattle.io.clusterrepo.vue +17 -3
  40. package/edit/cloudcredential.vue +2 -1
  41. package/edit/management.cattle.io.user.vue +60 -35
  42. package/edit/monitoring.coreos.com.alertmanagerconfig/receiverConfig.vue +11 -6
  43. package/edit/provisioning.cattle.io.cluster/index.vue +5 -4
  44. package/edit/provisioning.cattle.io.cluster/shared.ts +4 -2
  45. package/edit/secret/generic.vue +1 -0
  46. package/edit/secret/index.vue +2 -1
  47. package/edit/service.vue +2 -14
  48. package/edit/token.vue +29 -68
  49. package/list/management.cattle.io.feature.vue +7 -1
  50. package/list/provisioning.cattle.io.cluster.vue +0 -49
  51. package/mixins/brand.js +2 -1
  52. package/models/catalog.cattle.io.clusterrepo.js +9 -0
  53. package/models/cluster.x-k8s.io.machinedeployment.js +8 -3
  54. package/models/management.cattle.io.authconfig.js +2 -1
  55. package/models/management.cattle.io.cluster.js +4 -3
  56. package/models/monitoring.coreos.com.receiver.js +11 -6
  57. package/models/provisioning.cattle.io.cluster.js +2 -2
  58. package/models/token.js +0 -4
  59. package/package.json +12 -12
  60. package/pages/account/index.vue +67 -96
  61. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +66 -9
  62. package/pages/c/_cluster/apps/charts/index.vue +3 -8
  63. package/pages/c/_cluster/apps/charts/install.vue +8 -9
  64. package/pages/c/_cluster/explorer/index.vue +2 -19
  65. package/pages/c/_cluster/istio/index.vue +4 -2
  66. package/pages/c/_cluster/longhorn/index.vue +2 -1
  67. package/pages/c/_cluster/monitoring/index.vue +2 -2
  68. package/pages/c/_cluster/neuvector/index.vue +2 -1
  69. package/pages/c/_cluster/settings/performance.vue +0 -5
  70. package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +2 -1
  71. package/pages/c/_cluster/uiplugins/index.vue +2 -1
  72. package/pkg/auto-import.js +41 -0
  73. package/plugins/dashboard-store/resource-class.js +2 -2
  74. package/plugins/steve/__tests__/steve-class.test.ts +1 -1
  75. package/plugins/steve/steve-class.js +3 -3
  76. package/plugins/steve/steve-pagination-utils.ts +2 -5
  77. package/plugins/steve/subscribe.js +29 -4
  78. package/rancher-components/Pill/RcCounterBadge/RcCounterBadge.vue +7 -7
  79. package/rancher-components/Pill/RcStatusBadge/RcStatusBadge.vue +5 -2
  80. package/rancher-components/RcButton/RcButton.vue +3 -3
  81. package/rancher-components/RcButtonSplit/RcButtonSplit.test.ts +253 -0
  82. package/rancher-components/RcButtonSplit/RcButtonSplit.vue +158 -0
  83. package/rancher-components/RcButtonSplit/index.ts +1 -0
  84. package/rancher-components/RcIcon/types.ts +2 -2
  85. package/rancher-components/RcSection/RcSection.test.ts +323 -0
  86. package/rancher-components/RcSection/RcSection.vue +252 -0
  87. package/rancher-components/RcSection/RcSectionActions.test.ts +212 -0
  88. package/rancher-components/RcSection/RcSectionActions.vue +85 -0
  89. package/rancher-components/RcSection/RcSectionBadges.test.ts +149 -0
  90. package/rancher-components/RcSection/RcSectionBadges.vue +29 -0
  91. package/rancher-components/RcSection/index.ts +12 -0
  92. package/rancher-components/RcSection/types.ts +86 -0
  93. package/scripts/test-plugins-build.sh +9 -8
  94. package/types/shell/index.d.ts +93 -108
  95. package/utils/__tests__/require-asset.test.ts +98 -0
  96. package/utils/async.ts +1 -5
  97. package/utils/brand.ts +3 -1
  98. package/utils/favicon.js +4 -3
  99. package/utils/require-asset.ts +95 -0
  100. package/utils/style.ts +17 -0
  101. package/utils/units.js +14 -5
  102. package/vue.config.js +4 -3
  103. package/components/HarvesterServiceAddOnConfig.vue +0 -207
  104. package/models/ext.cattle.io.token.js +0 -48
@@ -38,8 +38,30 @@ jest.mock('@shell/core/plugin', () => {
38
38
  };
39
39
  });
40
40
 
41
- // Mock ExtensionPoint
42
- jest.mock('@shell/core/types', () => ({ ExtensionPoint: { EDIT_YAML: 'edit-yaml' } }));
41
+ // Mock ExtensionPoint — only EDIT_YAML, simulating an older dashboard (e.g. 2.13)
42
+ // that does not know about newer ExtensionPoints like 'Table'.
43
+ // The ensureUIConfigCompat tests override this to include TABLE for plugin-helpers,
44
+ // simulating an extension shipping a newer shell.
45
+ jest.mock('@shell/core/types', () => ({
46
+ ExtensionPoint: { EDIT_YAML: 'edit-yaml' },
47
+ ActionLocation: { TABLE: 'table-action' },
48
+ CardLocation: {},
49
+ }));
50
+
51
+ jest.mock('@shell/utils/platform', () => ({ isMac: false }));
52
+ jest.mock('@shell/utils/string', () => ({
53
+ ucFirst: jest.fn((s) => s),
54
+ randomStr: jest.fn(() => 'abc123'),
55
+ }));
56
+ jest.mock('@shell/config/query-params', () => ({
57
+ _EDIT: 'edit',
58
+ _CONFIG: 'config',
59
+ _DETAIL: 'detail',
60
+ _LIST: 'list',
61
+ _CREATE: 'create',
62
+ }));
63
+ jest.mock('@shell/utils/router', () => ({ getProductFromRoute: jest.fn() }));
64
+ jest.mock('@shell/utils/object', () => ({ isEqual: jest.fn() }));
43
65
 
44
66
  // Mock PluginRoutes
45
67
  jest.mock('@shell/core/plugin-routes', () => {
@@ -299,6 +321,28 @@ describe('extension Manager', () => {
299
321
  delete window[pluginId];
300
322
  });
301
323
 
324
+ it('surfaces the plugin id and original error message when initialization fails', async() => {
325
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
326
+ const pluginId = 'surface-error-plugin';
327
+ const originalError = new Error('something went wrong');
328
+ const mockPluginInit = jest.fn().mockImplementation(() => {
329
+ throw originalError;
330
+ });
331
+
332
+ window[pluginId] = { default: mockPluginInit };
333
+
334
+ const loadPromise = manager.loadAsync(pluginId, 'test.js');
335
+ const script = document.head.querySelector(`script[id="${ pluginId }"]`);
336
+
337
+ script.onload();
338
+
339
+ await expect(loadPromise).rejects.toThrow(`Could not initialize plugin ${ pluginId } - ${ originalError.message }`);
340
+ expect(consoleErrorSpy).toHaveBeenCalledWith(`Could not initialize plugin ${ pluginId }`, originalError);
341
+
342
+ consoleErrorSpy.mockRestore();
343
+ delete window[pluginId];
344
+ });
345
+
302
346
  it('rejects if script load fails', async() => {
303
347
  const pluginId = 'fail-plugin';
304
348
  const loadPromise = manager.loadAsync(pluginId, 'bad-url.js');
@@ -433,5 +477,146 @@ describe('extension Manager', () => {
433
477
 
434
478
  expect(config).toStrictEqual([]);
435
479
  });
480
+
481
+ it('returns empty array for non-existent type', () => {
482
+ // Simulates an extension using a newer ExtensionPoint (e.g. 'Table')
483
+ // that the running dashboard does not have in its uiConfig
484
+ const config = manager.getUIConfig('unknown-type', 'some-area');
485
+
486
+ expect(config).toStrictEqual([]);
487
+ });
488
+ });
489
+
490
+ describe('ensureUIConfigCompat (via plugin-helpers)', () => {
491
+ // These tests verify that getApplicableExtensionEnhancements tracks missing
492
+ // ExtensionPoint keys and exits early when accessing them.
493
+ // This is the forwards-compatibility mechanism for extensions running on
494
+ // older dashboards that don't know about newer ExtensionPoints (e.g. 'Table').
495
+
496
+ const mockRoute = {
497
+ name: 'test',
498
+ params: {},
499
+ query: {},
500
+ meta: {},
501
+ hash: '',
502
+ path: '/test',
503
+ };
504
+
505
+ // Each test needs a fresh plugin-helpers module to reset the _uiConfigPatched tracking.
506
+ // The extension-manager is created with the "old" ExtensionPoint (only EDIT_YAML),
507
+ // then plugin-helpers is re-required with an "upgraded" ExtensionPoint that includes TABLE,
508
+ // simulating an extension built with a newer shell running on an older dashboard.
509
+ let freshGetApplicable;
510
+ let freshManager;
511
+
512
+ beforeEach(() => {
513
+ freshManager = createExtensionManager(context);
514
+
515
+ // Reset modules so plugin-helpers._uiConfigPatched starts as empty object
516
+ jest.resetModules();
517
+
518
+ // Override the types mock so plugin-helpers sees a newer ExtensionPoint with TABLE
519
+ jest.doMock('@shell/core/types', () => ({
520
+ ExtensionPoint: {
521
+ EDIT_YAML: 'edit-yaml',
522
+ TABLE: 'Table',
523
+ },
524
+ ActionLocation: { TABLE: 'table-action' },
525
+ CardLocation: {},
526
+ }));
527
+
528
+ freshGetApplicable = require('@shell/core/plugin-helpers').getApplicableExtensionEnhancements;
529
+ });
530
+
531
+ it('returns empty array for missing ExtensionPoint and does not mutate uiConfig', () => {
532
+ // Verify 'Table' is NOT in uiConfig before the call
533
+ const uiConfig = freshManager.getAllUIConfig();
534
+
535
+ expect(uiConfig['Table']).toBeUndefined();
536
+
537
+ // Call getApplicableExtensionEnhancements which triggers ensureUIConfigCompat
538
+ const result = freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
539
+
540
+ // Should return empty array for missing extension point
541
+ expect(result).toStrictEqual([]);
542
+
543
+ // uiConfig should NOT be mutated - 'Table' should remain undefined
544
+ expect(uiConfig['Table']).toBeUndefined();
545
+ });
546
+
547
+ it('does not affect existing ExtensionPoint keys', () => {
548
+ const mockAction = { label: 'Test', locationConfig: {} };
549
+ const plugin = {
550
+ types: {},
551
+ uiConfig: { 'edit-yaml': { header: [mockAction] } },
552
+ l10n: {},
553
+ modelExtensions: {},
554
+ stores: [],
555
+ locales: [],
556
+ routes: [],
557
+ validators: {},
558
+ productNames: [],
559
+ };
560
+
561
+ freshManager.applyPlugin(plugin);
562
+
563
+ const result = freshGetApplicable({ $extension: freshManager }, 'edit-yaml', 'header', mockRoute);
564
+
565
+ // Should return the actual action
566
+ expect(result).toHaveLength(1);
567
+ expect(result[0].label).toBe('Test');
568
+
569
+ // Existing config should be untouched
570
+ const uiConfig = freshManager.getAllUIConfig();
571
+
572
+ expect(uiConfig['edit-yaml'].header).toHaveLength(1);
573
+ expect(uiConfig['edit-yaml'].header[0]).toBe(mockAction);
574
+ });
575
+
576
+ it('logs a warning for missing ExtensionPoints', () => {
577
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
578
+
579
+ freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
580
+
581
+ expect(consoleWarnSpy).toHaveBeenCalledWith(
582
+ expect.stringContaining('Table')
583
+ );
584
+
585
+ consoleWarnSpy.mockRestore();
586
+ });
587
+
588
+ it('dashboard-side getUIConfig calls remain safe with optional chaining', () => {
589
+ // getUIConfig('Table', ...) should not crash even though 'Table' is missing
590
+ // Returns empty array due to optional chaining in extension-manager
591
+ expect(freshManager.getUIConfig('Table', 'some-area')).toStrictEqual([]);
592
+
593
+ // Trigger ensureUIConfigCompat via getApplicableExtensionEnhancements
594
+ const result = freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
595
+
596
+ // getApplicableExtensionEnhancements exits early and returns empty array
597
+ expect(result).toStrictEqual([]);
598
+
599
+ // uiConfig is NOT mutated - 'Table' key still doesn't exist
600
+ const uiConfig = freshManager.getAllUIConfig();
601
+
602
+ expect(uiConfig['Table']).toBeUndefined();
603
+
604
+ // Direct getUIConfig calls still work safely (optional chaining)
605
+ expect(freshManager.getUIConfig('Table', 'some-area')).toStrictEqual([]);
606
+ });
607
+
608
+ it('only logs warning once for each missing ExtensionPoint', () => {
609
+ const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
610
+
611
+ // First call logs warning and tracks 'Table' as missing
612
+ freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
613
+ // Second call should not log warning again (already tracked)
614
+ freshGetApplicable({ $extension: freshManager }, 'Table', 'some-area', mockRoute);
615
+
616
+ // Warning should only be logged once
617
+ expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
618
+
619
+ consoleWarnSpy.mockRestore();
620
+ });
436
621
  });
437
622
  });
@@ -124,7 +124,9 @@ export const createExtensionManager = (context) => {
124
124
  } catch (e) {
125
125
  delete plugins[id];
126
126
 
127
- return reject(new Error('Could not initialize plugin'));
127
+ console.error(`Could not initialize plugin ${ id }`, e); // eslint-disable-line no-console
128
+
129
+ return reject(new Error(`Could not initialize plugin ${ id } - ${ e?.message }`));
128
130
  }
129
131
 
130
132
  // Load all of the types etc from the plugin
@@ -426,7 +428,7 @@ export const createExtensionManager = (context) => {
426
428
  * Return the UI configuration for the given type and location
427
429
  */
428
430
  getUIConfig(type, uiArea) {
429
- return uiConfig[type][uiArea] || [];
431
+ return uiConfig[type]?.[uiArea] || [];
430
432
  },
431
433
 
432
434
  /**
@@ -137,6 +137,30 @@ function checkExtensionRouteBinding($route: any, locationConfig: any, context: a
137
137
  return res;
138
138
  }
139
139
 
140
+ // Track which ExtensionPoint keys are missing from the extension manager's uiConfig.
141
+ // This handles forwards-compatibility when extensions ship a newer shell that defines
142
+ // ExtensionPoint values the running dashboard doesn't know about (e.g. 'Table' on 2.13).
143
+ const _uiConfigPatched: { [point: string]: boolean } = {};
144
+
145
+ function ensureUIConfigCompat(extensionManager: any) {
146
+ const uiConfig = extensionManager.getAllUIConfig?.();
147
+
148
+ if (uiConfig) {
149
+ const missingPoints: { [point: string]: boolean } = {};
150
+
151
+ Object.values(ExtensionPoint).forEach((ep) => {
152
+ if (!uiConfig[ep] && !_uiConfigPatched[ep]) {
153
+ missingPoints[ep] = true;
154
+ _uiConfigPatched[ep] = true;
155
+ }
156
+ });
157
+
158
+ if (Object.keys(missingPoints).length) {
159
+ console.warn(`[plugin-helpers] These ExtensionPoints aren't available for usage in this Rancher version: ${ Object.keys(missingPoints).join(', ') }`); // eslint-disable-line no-console
160
+ }
161
+ }
162
+ }
163
+
140
164
  export function getApplicableExtensionEnhancements<T>(
141
165
  pluginCtx: ComponentOptionsMixin,
142
166
  actionType: ExtensionPoint,
@@ -148,6 +172,13 @@ export function getApplicableExtensionEnhancements<T>(
148
172
 
149
173
  // gate it so that we prevent errors on older versions of dashboard
150
174
  if (pluginCtx.$extension?.getUIConfig) {
175
+ ensureUIConfigCompat(pluginCtx.$extension);
176
+
177
+ // Exit early if actionType doesn't exist in the extension manager's uiConfig
178
+ if (_uiConfigPatched[actionType]) {
179
+ return [];
180
+ }
181
+
151
182
  const actions = pluginCtx.$extension.getUIConfig(actionType, uiArea);
152
183
 
153
184
  actions.forEach((action: any, i: number) => {
@@ -0,0 +1,83 @@
1
+ import { shallowMount } from '@vue/test-utils';
2
+ import node from '@shell/detail/node.vue';
3
+ import ConsumptionGauge from '@shell/components/ConsumptionGauge.vue';
4
+
5
+ describe('view: node detail', () => {
6
+ const mockStore = {
7
+ getters: {
8
+ 'cluster/schemaFor': () => undefined,
9
+ 'cluster/paginationEnabled': () => false,
10
+ 'type-map/headersFor': jest.fn(),
11
+ 'i18n/t': (key: string) => key,
12
+ currentCluster: { id: 'local' },
13
+ },
14
+ dispatch: jest.fn(),
15
+ };
16
+
17
+ const mocks = {
18
+ $store: mockStore,
19
+ $fetchState: { pending: false },
20
+ };
21
+
22
+ const defaultNodeValue = {
23
+ metadata: { name: 'test-node' },
24
+ status: {
25
+ nodeInfo: {}, images: [], conditions: []
26
+ },
27
+ spec: { taints: [] },
28
+ pods: [],
29
+ cpuCapacity: 4,
30
+ cpuUsage: 2,
31
+ ramReserved: 8000,
32
+ ramUsage: 4000,
33
+ podCapacity: 110,
34
+ podConsumed: 5,
35
+ isPidPressureOk: true,
36
+ isDiskPressureOk: true,
37
+ isMemoryPressureOk: true,
38
+ isKubeletOk: true,
39
+ internalIp: '10.0.0.1',
40
+ };
41
+
42
+ function createWrapper(nodeOverrides = {}) {
43
+ return shallowMount(node, {
44
+ props: { value: { ...defaultNodeValue, ...nodeOverrides } },
45
+ global: { mocks },
46
+ });
47
+ }
48
+
49
+ function findGaugeByResource(wrapper: ReturnType<typeof createWrapper>, resourceKey: string) {
50
+ const gauges = wrapper.findAllComponents(ConsumptionGauge);
51
+
52
+ return gauges.find(
53
+ (g) => g.attributes('resourcename')?.includes(resourceKey)
54
+ );
55
+ }
56
+
57
+ it('should pass the "running" translation key as usedlabel to the pods ConsumptionGauge', () => {
58
+ const wrapper = createWrapper();
59
+
60
+ const podsGauge = findGaugeByResource(wrapper, 'consumptionGauge.pods');
61
+
62
+ expect(podsGauge).toBeDefined();
63
+ expect(podsGauge!.attributes('usedlabel')).toContain('consumptionGauge.running');
64
+ });
65
+
66
+ it('should NOT pass a usedlabel to the CPU ConsumptionGauge', () => {
67
+ const wrapper = createWrapper();
68
+
69
+ const cpuGauge = findGaugeByResource(wrapper, 'consumptionGauge.cpu');
70
+
71
+ expect(cpuGauge).toBeDefined();
72
+ expect(cpuGauge!.attributes('usedlabel')).toBeUndefined();
73
+ });
74
+
75
+ it('should NOT pass a usedlabel to the Memory ConsumptionGauge', () => {
76
+ const wrapper = createWrapper();
77
+
78
+ const memoryGauge = findGaugeByResource(wrapper, 'consumptionGauge.memory');
79
+
80
+ expect(memoryGauge).toBeDefined();
81
+ expect(memoryGauge!.attributes('usedlabel')).toBeUndefined();
82
+ });
83
+ });
@@ -9,6 +9,7 @@ import DateComponent from '@shell/components/formatter/Date.vue';
9
9
  import { RcItemCard } from '@components/RcItemCard';
10
10
  import ActionMenu, { type ActionMenuSelection } from '@shell/components/ActionMenuShell.vue';
11
11
  import { Banner } from '@components/Banner';
12
+ import keySvg from '~shell/assets/images/key.svg';
12
13
 
13
14
  type SecretActionType = 'create-secret' | 'regen-secret' | 'remove-secret'
14
15
  interface ClientSecretData { createdAt: string, lastUsedAt: string, lastFiveCharacters: string }
@@ -228,7 +229,7 @@ export default defineComponent({
228
229
  clientSecrets.push({
229
230
  id: oidcSecretDataKey,
230
231
  header: { title: { text: oidcSecretDataKey } },
231
- image: { src: require('~shell/assets/images/key.svg') },
232
+ image: { src: keySvg },
232
233
  createdAt,
233
234
  lastFiveCharacters: oidcSecretData.lastFiveCharacters,
234
235
  lastUsedAt,
package/detail/node.vue CHANGED
@@ -234,6 +234,7 @@ export default {
234
234
  :resource-name="t('node.detail.glance.consumptionGauge.pods')"
235
235
  :capacity="value.podCapacity"
236
236
  :used="value.podConsumed"
237
+ :used-label="t('node.detail.glance.consumptionGauge.running')"
237
238
  />
238
239
  </div>
239
240
  <div class="spacer" />
@@ -49,6 +49,7 @@ defineProps<{
49
49
  <CopyToClipboard
50
50
  label-as="tooltip"
51
51
  :text="tArgs.serverUrl"
52
+ :aria-label="t('generic.copyValueToClipboard', { value: tArgs.serverUrl })"
52
53
  class="icon-btn"
53
54
  action-color="bg-transparent"
54
55
  />
@@ -67,6 +68,7 @@ defineProps<{
67
68
  <CopyToClipboard
68
69
  :text="t(`authConfig.${name}.form.callback.value`, tArgs, true)"
69
70
  label-as="tooltip"
71
+ :aria-label="t('generic.copyValueToClipboard', { value: t(`authConfig.${name}.form.callback.value`, tArgs, true) })"
70
72
  class="icon-btn"
71
73
  action-color="bg-transparent"
72
74
  />
@@ -40,6 +40,7 @@ defineProps<{
40
40
  <b>{{ t(`authConfig.${name}.form.homepage.label`) }}</b>: {{ tArgs.serverUrl }} <CopyToClipboard
41
41
  label-as="tooltip"
42
42
  :text="tArgs.serverUrl"
43
+ :aria-label="t('generic.copyValueToClipboard', { value: tArgs.serverUrl })"
43
44
  class="icon-btn"
44
45
  action-color="bg-transparent"
45
46
  />
@@ -49,6 +50,7 @@ defineProps<{
49
50
  <b>{{ t(`authConfig.${name}.form.callback.label`) }}</b>: {{ tArgs.serverUrl }} <CopyToClipboard
50
51
  :text="tArgs.serverUrl"
51
52
  label-as="tooltip"
53
+ :aria-label="t('generic.copyValueToClipboard', { value: tArgs.serverUrl })"
52
54
  class="icon-btn"
53
55
  action-color="bg-transparent"
54
56
  />
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import CreateEditView from '@shell/mixins/create-edit-view';
3
+ import AsyncButton from '@shell/components/AsyncButton.vue';
3
4
  import Footer from '@shell/components/form/Footer';
4
5
  import { LabeledInput } from '@components/Form/LabeledInput';
5
6
  import NameNsDescription from '@shell/components/form/NameNsDescription';
@@ -16,6 +17,7 @@ import { getVersionData } from '@shell/config/version';
16
17
  import { RcItemCard } from '@components/RcItemCard';
17
18
  import { _CREATE, _EDIT, TARGET, _VIEW } from '@shell/config/query-params';
18
19
  import { RcIconType } from '@components/RcIcon/types';
20
+ import { requireAsset } from '@shell/utils/require-asset';
19
21
 
20
22
  export default {
21
23
  name: 'CruCatalogRepo',
@@ -23,6 +25,7 @@ export default {
23
25
  emits: ['input'],
24
26
 
25
27
  components: {
28
+ AsyncButton,
26
29
  Footer,
27
30
  LabeledInput,
28
31
  NameNsDescription,
@@ -64,7 +67,7 @@ export default {
64
67
  {
65
68
  id: CLUSTER_REPO_TYPES.OCI_URL,
66
69
  header: { title: { key: 'catalog.repo.target.oci.title' } },
67
- image: { src: require('@shell/assets/images/providers/oci-open-containers.svg'), alt: { key: 'catalog.repo.target.oci.title' } },
70
+ image: { src: requireAsset('@shell/assets/images/providers/oci-open-containers.svg'), alt: { key: 'catalog.repo.target.oci.title' } },
68
71
  content: { key: 'catalog.repo.target.oci.description' },
69
72
  },
70
73
  ];
@@ -74,7 +77,7 @@ export default {
74
77
  clusterRepoTargets.push({
75
78
  id: CLUSTER_REPO_TYPES.SUSE_APP_COLLECTION,
76
79
  header: { title: { key: 'catalog.repo.target.suseAppCollection.title' } },
77
- image: { src: require('@shell/assets/images/content/suse.svg'), alt: { key: 'catalog.repo.target.suseAppCollection.title' } },
80
+ image: { src: requireAsset('@shell/assets/images/content/suse.svg'), alt: { key: 'catalog.repo.target.suseAppCollection.title' } },
78
81
  content: { key: 'catalog.repo.target.suseAppCollection.description' },
79
82
  });
80
83
  }
@@ -90,6 +93,7 @@ export default {
90
93
  ociMaxRetries: this.value.spec.exponentialBackOffValues?.maxRetries,
91
94
  getVersionData,
92
95
  isView: this.mode === _VIEW,
96
+ isCreate: this.mode === _CREATE,
93
97
  clusterRepoTargets,
94
98
  previousName: '',
95
99
  previousDescription: '',
@@ -450,7 +454,17 @@ export default {
450
454
  :errors="errors"
451
455
  @save="save"
452
456
  @done="done"
453
- />
457
+ >
458
+ <template
459
+ v-if="isCreate"
460
+ #save
461
+ >
462
+ <AsyncButton
463
+ :action-label="t('catalog.repo.add')"
464
+ @click="save"
465
+ />
466
+ </template>
467
+ </Footer>
454
468
  </form>
455
469
  </template>
456
470
 
@@ -3,6 +3,7 @@ import { SECRET_TYPES as TYPES } from '@shell/config/secret';
3
3
  import { MANAGEMENT, NORMAN, SCHEMA, DEFAULT_WORKSPACE } from '@shell/config/types';
4
4
  import CreateEditView from '@shell/mixins/create-edit-view';
5
5
  import NameNsDescription from '@shell/components/form/NameNsDescription';
6
+ import { requireAsset } from '@shell/utils/require-asset';
6
7
  import CruResource from '@shell/components/CruResource';
7
8
  import { _CREATE, _EDIT } from '@shell/config/query-params';
8
9
  import Loading from '@shell/components/Loading';
@@ -177,7 +178,7 @@ export default {
177
178
 
178
179
  if (!bannerImage) {
179
180
  try {
180
- bannerImage = require(`~shell/assets/images/providers/${ id }.svg`);
181
+ bannerImage = requireAsset(`~shell/assets/images/providers/${ id }.svg`);
181
182
  } catch (e) {
182
183
  bannerImage = null;
183
184
  bannerAbbrv = this.initialDisplayFor(id);
@@ -16,8 +16,6 @@ export default {
16
16
  ChangePassword, GlobalRoleBindings, CruResource, LabeledInput, Loading
17
17
  },
18
18
 
19
- emits: ['update:mode'],
20
-
21
19
  mixins: [
22
20
  CreateEditView
23
21
  ],
@@ -42,8 +40,7 @@ export default {
42
40
  password: false,
43
41
  roles: !showGlobalRoles,
44
42
  rolesChanged: false,
45
- },
46
- watchOverride: false,
43
+ }
47
44
  };
48
45
  },
49
46
 
@@ -110,10 +107,20 @@ export default {
110
107
  if (this.isCreate) {
111
108
  const user = await this.createUser();
112
109
 
113
- await this.updateRoles(user.id);
110
+ await this.createSecret(user);
111
+ await this.updateRoles(user);
112
+
113
+ // Show success notification only after ALL operations complete
114
+ // this is a "clone" of steve-class "processSaveResponse" toast/growl
115
+ this.$store.dispatch('growl/success', {
116
+ title: this.t('generic.autogeneratedCreated.title', { resource: user.kind }),
117
+ message: this.t('generic.autogeneratedCreated.message', { id: user.username, resource: user.kind }),
118
+ timeout: 3000
119
+ }, { root: true });
114
120
  } else {
115
- await this.editUser();
116
- await this.updateRoles();
121
+ const user = await this.editUser();
122
+
123
+ await this.updateRoles(user);
117
124
  }
118
125
 
119
126
  this.$router.replace({ name: this.doneRoute });
@@ -139,21 +146,10 @@ export default {
139
146
  username: this.form.username
140
147
  });
141
148
 
142
- const userSaved = await user.save();
143
-
144
- if (this.form.password.password) {
145
- // create secret to hold user password
146
- const secret = await this.$store.dispatch('management/create', {
147
- type: SECRET,
148
- metadata: {
149
- namespace: 'cattle-local-user-passwords',
150
- name: userSaved.id
151
- },
152
- data: { password: base64Encode(this.form.password.password) }
153
- });
154
-
155
- await secret.save();
156
- }
149
+ const userSaved = await user.save({
150
+ // Don't show a success toast until the secret and GRB are also created
151
+ suppressSuccessToast: true,
152
+ });
157
153
 
158
154
  return userSaved;
159
155
  },
@@ -178,27 +174,57 @@ export default {
178
174
  await wait(5000);
179
175
  }
180
176
 
181
- this.value.save();
177
+ const user = this.value.save();
178
+
179
+ return user;
182
180
  },
183
181
 
184
- async updateRoles(userId) {
182
+ async createSecret(user) {
183
+ if (this.form.password.password) {
184
+ try {
185
+ // create secret to hold user password
186
+ const secret = await this.$store.dispatch('management/create', {
187
+ type: SECRET,
188
+ metadata: {
189
+ namespace: 'cattle-local-user-passwords',
190
+ name: user.id
191
+ },
192
+ data: { password: base64Encode(this.form.password.password) }
193
+ });
194
+
195
+ await secret.save();
196
+ } catch (err) {
197
+ if (this.isCreate) {
198
+ try {
199
+ // If secret creation fails, attempt to clean up the user to maintain consistency
200
+ await user.remove();
201
+ } catch (cleanupErr) {
202
+ // Log cleanup error but prioritize original error for user feedback
203
+ console.error('Failed to clean up user after secret creation failure:', cleanupErr); // eslint-disable-line no-console
204
+ }
205
+ }
206
+
207
+ throw err;
208
+ }
209
+ }
210
+ },
211
+
212
+ async updateRoles(user) {
185
213
  if (!this.$refs.grb) {
186
214
  return;
187
215
  }
188
216
 
189
217
  try {
190
- await this.$refs.grb.save(userId);
218
+ await this.$refs.grb.save(user.id);
191
219
  } catch (err) {
192
220
  if (this.isCreate) {
193
- this.watchOverride = true;
194
- this.$emit(
195
- 'update:mode',
196
- {
197
- userId,
198
- mode: _EDIT,
199
- resource: 'management.cattle.io.user',
200
- }
201
- );
221
+ try {
222
+ // If GRB creation fails, clean up the user to maintain consistency
223
+ await user.remove();
224
+ } catch (cleanupErr) {
225
+ // Log cleanup error but prioritize original error for user feedback
226
+ console.error('Failed to clean up user after GRB creation failure:', cleanupErr); // eslint-disable-line no-console
227
+ }
202
228
  }
203
229
  throw err;
204
230
  }
@@ -274,7 +300,6 @@ export default {
274
300
  :user-id="value.id || liveValue.id"
275
301
  :mode="mode"
276
302
  :real-mode="realMode"
277
- :watch-override="watchOverride"
278
303
  type="user"
279
304
  @hasChanges="validation.rolesChanged = $event"
280
305
  @canLogIn="validation.roles = $event"