@rancher/shell 3.0.9 → 3.0.10

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 (45) 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 +2 -5
  5. package/components/CopyToClipboard.vue +28 -0
  6. package/components/CopyToClipboardText.vue +4 -0
  7. package/components/CruResource.vue +1 -0
  8. package/components/GlobalRoleBindings.vue +1 -5
  9. package/components/ResourceDetail/index.vue +0 -21
  10. package/components/__tests__/CruResource.test.ts +35 -1
  11. package/composables/useIsNewDetailPageEnabled.test.ts +98 -0
  12. package/composables/useIsNewDetailPageEnabled.ts +12 -0
  13. package/config/product/explorer.js +11 -1
  14. package/config/table-headers.js +0 -9
  15. package/config/types.js +0 -1
  16. package/edit/auth/github-app-steps.vue +2 -0
  17. package/edit/auth/github-steps.vue +2 -0
  18. package/edit/management.cattle.io.user.vue +60 -35
  19. package/edit/token.vue +29 -68
  20. package/models/token.js +0 -4
  21. package/package.json +8 -8
  22. package/pages/account/index.vue +67 -96
  23. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +66 -9
  24. package/pages/c/_cluster/explorer/index.vue +2 -19
  25. package/pkg/auto-import.js +41 -0
  26. package/plugins/dashboard-store/resource-class.js +2 -2
  27. package/plugins/steve/__tests__/steve-class.test.ts +1 -1
  28. package/plugins/steve/steve-class.js +3 -3
  29. package/plugins/steve/steve-pagination-utils.ts +2 -4
  30. package/rancher-components/Pill/RcCounterBadge/RcCounterBadge.vue +7 -7
  31. package/rancher-components/Pill/RcStatusBadge/RcStatusBadge.vue +5 -2
  32. package/rancher-components/RcIcon/types.ts +2 -2
  33. package/rancher-components/RcSection/RcSection.test.ts +323 -0
  34. package/rancher-components/RcSection/RcSection.vue +252 -0
  35. package/rancher-components/RcSection/RcSectionActions.test.ts +212 -0
  36. package/rancher-components/RcSection/RcSectionActions.vue +85 -0
  37. package/rancher-components/RcSection/RcSectionBadges.test.ts +149 -0
  38. package/rancher-components/RcSection/RcSectionBadges.vue +29 -0
  39. package/rancher-components/RcSection/index.ts +12 -0
  40. package/rancher-components/RcSection/types.ts +86 -0
  41. package/scripts/test-plugins-build.sh +5 -4
  42. package/types/shell/index.d.ts +92 -108
  43. package/utils/style.ts +17 -0
  44. package/utils/units.js +14 -5
  45. package/models/ext.cattle.io.token.js +0 -48
@@ -29,3 +29,7 @@ $gray003: #FFF;
29
29
  $gray004: #6C6C76;
30
30
  $gray005: #4A4B52;
31
31
  $gray006: #1b1c21;
32
+ $gray007: #161C24;
33
+ $gray008: #262831;
34
+ $gray009: #6C6C77;
35
+ $gray010: #B6B6C3;
@@ -571,17 +571,17 @@
571
571
  --rc-success: #{$green001};
572
572
  --rc-success-secondary: #{$green002};
573
573
 
574
- --rc-warning: #{$yellow001};
575
- --rc-warning-secondary: #{$yellow002};
574
+ --rc-warning: #{$yellow002};
575
+ --rc-warning-secondary: #{$yellow001};
576
576
 
577
577
  --rc-error: #{$red001};
578
578
  --rc-error-secondary: #{$red002};
579
579
 
580
- --rc-unknown: #{$gray001};
581
- --rc-unknown-secondary: #{$gray004};
580
+ --rc-unknown: #{$gray004};
581
+ --rc-unknown-secondary: #{$gray001};
582
582
 
583
- --rc-none: #{$gray002};
584
- --rc-none-secondary: #{$gray004};
583
+ --rc-none: #{$gray004};
584
+ --rc-none-secondary: #{$gray002};
585
585
 
586
586
  --rc-primary-hover: #{$blue003};
587
587
 
@@ -701,17 +701,17 @@ BODY, .theme-light {
701
701
  --rc-success: #{$green001};
702
702
  --rc-success-secondary: #{$green002};
703
703
 
704
- --rc-warning: #{$yellow001};
705
- --rc-warning-secondary: #{$yellow002};
704
+ --rc-warning: #{$yellow002};
705
+ --rc-warning-secondary: #{$yellow001};
706
706
 
707
707
  --rc-error: #{$red001};
708
708
  --rc-error-secondary: #{$red002};
709
709
 
710
- --rc-unknown: #{$gray001};
711
- --rc-unknown-secondary: #{$gray004};
710
+ --rc-unknown: #{$gray004};
711
+ --rc-unknown-secondary: #{$gray001};
712
712
 
713
- --rc-none: #{$gray002};
714
- --rc-none-secondary: #{$gray004};
713
+ --rc-none: #{$gray004};
714
+ --rc-none-secondary: #{$gray002};
715
715
 
716
716
  --rc-primary-hover: #{$blue003};
717
717
 
@@ -724,6 +724,10 @@ BODY, .theme-light {
724
724
  --rc-disabled-background: #{$gray001};
725
725
  --rc-disabled-text-color: #{$gray004};
726
726
 
727
+ --rc-section-background-primary: #{$lightest};
728
+ --rc-section-background-secondary: #{$lighter};
729
+ --rc-section-action-color: #{$gray009};
730
+
727
731
  --rc-image-bg: #{$lightest};
728
732
  --rc-image-color: #{$darkest};
729
733
 
@@ -1068,6 +1072,10 @@ BODY, .theme-dark {
1068
1072
  --rc-disabled-background: #{$gray005};
1069
1073
  --rc-disabled-text-color: #{$gray004};
1070
1074
 
1075
+ --rc-section-background-primary: #{$gray007};
1076
+ --rc-section-background-secondary: #{$gray008};
1077
+ --rc-section-action-color: #{$gray010};
1078
+
1071
1079
  --rc-image-bg: #{$lightest};
1072
1080
  --rc-image-color: #{$darkest};
1073
1081
 
@@ -30,6 +30,7 @@ generic:
30
30
  comma: ', '
31
31
  copy: Copy
32
32
  copyToClipboard: Copy text to Clipboard
33
+ copyValueToClipboard: 'Copy {value} to Clipboard'
33
34
  copiedToClipboard: Text copied to Clipboard
34
35
  create: Create
35
36
  created: Created
@@ -438,7 +439,6 @@ accountAndKeys:
438
439
  notAllowed: You do not have permission to manage API Keys
439
440
  apiEndpoint: "API Endpoint:"
440
441
  copyApiEnpoint: Copy API Endpoint to clipboard
441
- normanTokenDeprecation: The API Keys feature is being migrated to a new API. Any existing API Keys from the legacy API will continue to work, but new API Keys will be created using the new API.
442
442
  add:
443
443
  description:
444
444
  label: Description
@@ -464,9 +464,7 @@ accountAndKeys:
464
464
  month: Months
465
465
  year: Years
466
466
  scope: Scope
467
- userPrincipal: User Principal
468
467
  noScope: No Scope
469
- enabled: Token enabled
470
468
  info:
471
469
  accessKey: Access Key
472
470
  secretKey: Secret Key
@@ -475,7 +473,7 @@ accountAndKeys:
475
473
  keyCreated: A new API Key has been created
476
474
  bearerTokenTip: "Access Key and Secret Key can be sent as the username and password for HTTP Basic auth to authorize requests. You can also combine them to use as a Bearer token:"
477
475
  ttlLimitedWarning: The Expiry time for this API Key was reduced due to system configuration
478
- expiryOptionsWithNever: Since "auth-token-max-ttl-minutes" is set to <= 0, the API Key will not expire unless the "Automatically expire" option is set to "Custom" and a custom expiry time is set.
476
+
479
477
  addClusterMemberDialog:
480
478
  title: Add Cluster Member
481
479
 
@@ -6857,7 +6855,6 @@ storageClass:
6857
6855
  tooltip: By default the default storage class on the host Harvester cluster is used.
6858
6856
 
6859
6857
  tableHeaders:
6860
- isLegacy: Legacy
6861
6858
  assuredConcurrencyShares: Assured Concurrency Shares
6862
6859
  autoscaler: Autoscaler
6863
6860
  accessKey: Access Key
@@ -38,7 +38,35 @@ export default {
38
38
  success-label="Copied!"
39
39
  error-label="Error Copying"
40
40
  v-bind="$attrs"
41
+ :success-color="$attrs['action-color'] || 'role-primary'"
42
+ :waiting-color="$attrs['action-color'] || 'role-primary'"
41
43
  :delay="2000"
42
44
  @click="clicked"
43
45
  />
44
46
  </template>
47
+
48
+ <style lang="scss" scoped>
49
+ .icon-btn {
50
+ min-height: 24px;
51
+ min-width: 24px;
52
+ justify-content: center;
53
+ }
54
+
55
+ .bg-transparent {
56
+ &:active {
57
+ background-color: var(--primary-keyboard-focus);
58
+ color: var(--primary-text);
59
+ }
60
+
61
+ &:focus-visible {
62
+ @include focus-outline;
63
+ }
64
+ }
65
+
66
+ .role-primary {
67
+ &:active {
68
+ background-color: var(--primary-keyboard-focus);
69
+ color: var(--primary-text);
70
+ }
71
+ }
72
+ </style>
@@ -75,6 +75,10 @@ export default {
75
75
  }
76
76
  }
77
77
 
78
+ &:active {
79
+ color: var(--primary-keyboard-focus);
80
+ }
81
+
78
82
  &.copied {
79
83
  pointer-events: none;
80
84
  color: var(--success);
@@ -782,6 +782,7 @@ export default {
782
782
  </div>
783
783
  <slot name="form-footer">
784
784
  <CruResourceFooter
785
+ v-if="!isView"
785
786
  class="cru__footer"
786
787
  :mode="mode"
787
788
  :is-form="showAsForm"
@@ -49,10 +49,6 @@ export default {
49
49
  userId: {
50
50
  type: String,
51
51
  default: ''
52
- },
53
- watchOverride: {
54
- type: Boolean,
55
- default: true,
56
52
  }
57
53
  },
58
54
  async fetch() {
@@ -142,7 +138,7 @@ export default {
142
138
  this.update();
143
139
  },
144
140
  userId(userId, oldUserId) {
145
- if (userId === oldUserId || this.watchOverride === true) {
141
+ if (userId === oldUserId) {
146
142
  return;
147
143
  }
148
144
  this.update();
@@ -403,25 +403,6 @@ export default {
403
403
  // Remove id? How does subtype get in (cluster/node)
404
404
  this.detailComponent = this.$store.getters['type-map/importDetail'](detailResource, id);
405
405
  this.editComponent = this.$store.getters['type-map/importEdit'](editResource, id);
406
- },
407
- /**
408
- * Sets the mode and initializes the resource components.
409
- *
410
- * This method sets the mode of the component and configures the resource
411
- * components based on the provided user and resource.
412
- *
413
- * @param {Object} payload - An object containing the mode, user, and
414
- * resource properties.
415
- * @param {string} payload.mode - The mode to set.
416
- * @param {Object} payload.user - The user object containing user-specific
417
- * information.
418
- * @param {string} payload.resource - The resource string to use for
419
- * initialization.
420
- */
421
- setMode({ mode, userId, resource }) {
422
- this.mode = mode;
423
- this.value.id = userId;
424
- this.configureResource(userId, resource);
425
406
  }
426
407
  }
427
408
  };
@@ -444,7 +425,6 @@ export default {
444
425
  :class="{'flex-content': flexContent}"
445
426
  :resource-errors="errors"
446
427
  @update:value="$emit('input', $event)"
447
- @update:mode="setMode"
448
428
  @set-subtype="setSubtype"
449
429
  />
450
430
  <div v-else>
@@ -514,7 +494,6 @@ export default {
514
494
  :real-mode="realMode"
515
495
  :class="{'flex-content': flexContent}"
516
496
  @update:value="$emit('input', $event)"
517
- @update:mode="setMode"
518
497
  @set-subtype="setSubtype"
519
498
  />
520
499
 
@@ -1,6 +1,6 @@
1
1
  import { mount } from '@vue/test-utils';
2
2
  import CruResource from '@shell/components/CruResource.vue';
3
- import { _EDIT, _YAML } from '@shell/config/query-params';
3
+ import { _CREATE, _EDIT, _VIEW, _YAML } from '@shell/config/query-params';
4
4
  import TextAreaAutoGrow from '@components/Form/TextArea/TextAreaAutoGrow.vue';
5
5
 
6
6
  describe('component: CruResource', () => {
@@ -171,6 +171,40 @@ describe('component: CruResource', () => {
171
171
  expect(event.preventDefault).toHaveBeenCalledWith();
172
172
  });
173
173
 
174
+ it.each([
175
+ [_EDIT, true],
176
+ [_CREATE, true],
177
+ [_VIEW, false],
178
+ ])('should render CruResourceFooter when mode is %s: %s', (mode: string, shouldRender: boolean) => {
179
+ const wrapper = mount(CruResource, {
180
+ props: {
181
+ canYaml: false,
182
+ mode,
183
+ resource: {}
184
+ },
185
+ global: {
186
+ mocks: {
187
+ $store: {
188
+ getters: {
189
+ currentStore: () => 'current_store',
190
+ 'current_store/schemaFor': jest.fn(),
191
+ 'current_store/all': jest.fn(),
192
+ 'i18n/t': jest.fn(),
193
+ 'i18n/exists': jest.fn(),
194
+ },
195
+ dispatch: jest.fn(),
196
+ },
197
+ $route: { query: { AS: _YAML } },
198
+ $router: { applyQuery: jest.fn() },
199
+ },
200
+ }
201
+ });
202
+
203
+ const footer = wrapper.find('.cru-resource-footer');
204
+
205
+ expect(footer.exists()).toBe(shouldRender);
206
+ });
207
+
174
208
  it('should not prevent default events on keypress Enter', async() => {
175
209
  const event = { preventDefault: jest.fn() };
176
210
  const wrapper = mount(CruResource, {
@@ -0,0 +1,98 @@
1
+ import { useIsNewDetailPageEnabled } from '@shell/composables/useIsNewDetailPageEnabled';
2
+
3
+ const mockStore: any = { getters: {} };
4
+ const mockRoute: any = { query: {} };
5
+
6
+ jest.mock('vuex', () => ({ useStore: () => mockStore }));
7
+ jest.mock('vue-router', () => ({ useRoute: () => mockRoute }));
8
+
9
+ const mockGetVersionInfo = jest.fn(() => ({ fullVersion: '2.12.0' }));
10
+
11
+ jest.mock('@shell/utils/version', () => ({ getVersionInfo: (...args: any[]) => mockGetVersionInfo(...args) }));
12
+
13
+ describe('useIsNewDetailPageEnabled', () => {
14
+ beforeEach(() => {
15
+ mockRoute.query = {};
16
+ mockGetVersionInfo.mockReturnValue({ fullVersion: '2.12.0' });
17
+ });
18
+
19
+ describe('version gating', () => {
20
+ it('should return false when version is below 2.12.0', () => {
21
+ mockGetVersionInfo.mockReturnValue({ fullVersion: '2.11.9' });
22
+ const result = useIsNewDetailPageEnabled();
23
+
24
+ expect(result.value).toBe(false);
25
+ });
26
+
27
+ it('should return false when version is 2.10.0', () => {
28
+ mockGetVersionInfo.mockReturnValue({ fullVersion: '2.10.0' });
29
+ const result = useIsNewDetailPageEnabled();
30
+
31
+ expect(result.value).toBe(false);
32
+ });
33
+
34
+ it('should return true when version is exactly 2.12.0 and no legacy query', () => {
35
+ mockGetVersionInfo.mockReturnValue({ fullVersion: '2.12.0' });
36
+ const result = useIsNewDetailPageEnabled();
37
+
38
+ expect(result.value).toBe(true);
39
+ });
40
+
41
+ it('should return true when version is above 2.12.0', () => {
42
+ mockGetVersionInfo.mockReturnValue({ fullVersion: '2.13.0' });
43
+ const result = useIsNewDetailPageEnabled();
44
+
45
+ expect(result.value).toBe(true);
46
+ });
47
+
48
+ it('should return false when version is undefined', () => {
49
+ mockGetVersionInfo.mockReturnValue({ fullVersion: undefined });
50
+ const result = useIsNewDetailPageEnabled();
51
+
52
+ expect(result.value).toBe(false);
53
+ });
54
+
55
+ it('should return false when version is null', () => {
56
+ mockGetVersionInfo.mockReturnValue({ fullVersion: null });
57
+ const result = useIsNewDetailPageEnabled();
58
+
59
+ expect(result.value).toBe(false);
60
+ });
61
+
62
+ it('should handle pre-release version strings', () => {
63
+ mockGetVersionInfo.mockReturnValue({ fullVersion: 'v2.12.1-rc1' });
64
+ const result = useIsNewDetailPageEnabled();
65
+
66
+ expect(result.value).toBe(true);
67
+ });
68
+ });
69
+
70
+ describe('legacy query param (with version >= 2.12.0)', () => {
71
+ it('should return true when no legacy query param is present', () => {
72
+ const result = useIsNewDetailPageEnabled();
73
+
74
+ expect(result.value).toBe(true);
75
+ });
76
+
77
+ it('should return false when legacy query param is "true"', () => {
78
+ mockRoute.query = { legacy: 'true' };
79
+ const result = useIsNewDetailPageEnabled();
80
+
81
+ expect(result.value).toBe(false);
82
+ });
83
+
84
+ it('should return true when legacy query param is "false"', () => {
85
+ mockRoute.query = { legacy: 'false' };
86
+ const result = useIsNewDetailPageEnabled();
87
+
88
+ expect(result.value).toBe(true);
89
+ });
90
+
91
+ it('should return true when legacy query param has an unexpected value', () => {
92
+ mockRoute.query = { legacy: 'something' };
93
+ const result = useIsNewDetailPageEnabled();
94
+
95
+ expect(result.value).toBe(true);
96
+ });
97
+ });
98
+ });
@@ -1,6 +1,9 @@
1
1
  import { useRoute } from 'vue-router';
2
2
  import { LEGACY } from '@shell/config/query-params';
3
3
  import { computed } from 'vue';
4
+ import { getVersionInfo } from '@shell/utils/version';
5
+ import semver from 'semver';
6
+ import { useStore } from 'vuex';
4
7
 
5
8
  const enabledByDefault = true;
6
9
 
@@ -8,6 +11,15 @@ export const useIsNewDetailPageEnabled = () => {
8
11
  const route = useRoute();
9
12
 
10
13
  return computed(() => {
14
+ const store = useStore();
15
+ const { fullVersion } = getVersionInfo(store);
16
+
17
+ const coerced = semver.coerce(fullVersion) || { version: '0.0.0' };
18
+
19
+ if (!semver.gte(coerced.version, '2.12.0')) {
20
+ return false;
21
+ }
22
+
11
23
  if (enabledByDefault) {
12
24
  return route?.query?.[LEGACY] !== 'true';
13
25
  }
@@ -19,7 +19,7 @@ import {
19
19
  USER_ID, USERNAME, USER_DISPLAY_NAME, USER_PROVIDER, USER_LAST_LOGIN, USER_DISABLED_IN, USER_DELETED_IN, WORKLOAD_ENDPOINTS, STORAGE_CLASS_DEFAULT,
20
20
  STORAGE_CLASS_PROVISIONER, PERSISTENT_VOLUME_SOURCE,
21
21
  HPA_REFERENCE, MIN_REPLICA, MAX_REPLICA, CURRENT_REPLICA,
22
- DESCRIPTION, SUB_TYPE, PERSISTENT_VOLUME_CLAIM, RECLAIM_POLICY, PV_REASON, WORKLOAD_HEALTH_SCALE, POD_RESTARTS,
22
+ ACCESS_KEY, DESCRIPTION, EXPIRES, EXPIRY_STATE, LAST_USED, SUB_TYPE, AGE_NORMAN, SCOPE_NORMAN, PERSISTENT_VOLUME_CLAIM, RECLAIM_POLICY, PV_REASON, WORKLOAD_HEALTH_SCALE, POD_RESTARTS,
23
23
  DURATION, MESSAGE, REASON, EVENT_TYPE, OBJECT, ROLE, ROLES, VERSION, INTERNAL_EXTERNAL_IP, KUBE_NODE_OS, CPU, RAM, SECRET_DATA,
24
24
  EVENT_LAST_SEEN_TIME,
25
25
  EVENT_FIRST_SEEN_TIME,
@@ -546,6 +546,16 @@ export function init(store) {
546
546
  AGE
547
547
  ]);
548
548
 
549
+ headers(NORMAN.TOKEN, [
550
+ EXPIRY_STATE,
551
+ ACCESS_KEY,
552
+ DESCRIPTION,
553
+ SCOPE_NORMAN,
554
+ LAST_USED,
555
+ EXPIRES,
556
+ AGE_NORMAN
557
+ ]);
558
+
549
559
  virtualType({
550
560
  label: store.getters['i18n/t']('clusterIndexPage.header'),
551
561
  group: 'Root',
@@ -1033,15 +1033,6 @@ export const SCOPE_NORMAN = {
1033
1033
  sort: ['clusterId'],
1034
1034
  };
1035
1035
 
1036
- export const NORMAN_KEY_DEPRECATION = {
1037
- name: 'isNormanKeyDeprecated',
1038
- labelKey: 'tableHeaders.isLegacy',
1039
- value: (row) => row.isDeprecated ? 'True' : undefined,
1040
- sort: 'isDeprecated',
1041
- align: 'left',
1042
- dashIfEmpty: true,
1043
- };
1044
-
1045
1036
  export const EXPIRES = {
1046
1037
  name: 'expires',
1047
1038
  value: 'expiresAt',
package/config/types.js CHANGED
@@ -269,7 +269,6 @@ export const EXT = {
269
269
  GROUP_MEMBERSHIP_REFRESH_REQUESTS: 'ext.cattle.io.groupmembershiprefreshrequest',
270
270
  PASSWORD_CHANGE_REQUESTS: 'ext.cattle.io.passwordchangerequest',
271
271
  KUBECONFIG: 'ext.cattle.io.kubeconfig',
272
- TOKEN: 'ext.cattle.io.token',
273
272
  };
274
273
 
275
274
  export const CAPI = {
@@ -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
  />
@@ -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"