@rancher/shell 3.0.12-rc.4 → 3.0.12-rc.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.
Files changed (81) hide show
  1. package/assets/styles/global/_button.scss +1 -1
  2. package/assets/translations/en-us.yaml +39 -10
  3. package/components/ActionDropdownShell.vue +5 -3
  4. package/components/ButtonGroup.vue +26 -1
  5. package/components/CruResource.vue +51 -2
  6. package/components/PromptRestore.vue +93 -32
  7. package/components/Questions/index.vue +1 -0
  8. package/components/ResourceTable.vue +1 -0
  9. package/components/SortableTable/index.vue +4 -3
  10. package/components/Wizard.vue +14 -1
  11. package/components/__tests__/ButtonGroup.test.ts +56 -0
  12. package/components/__tests__/PromptRestore.test.ts +169 -19
  13. package/components/fleet/GitRepoAdvancedTab.vue +1 -0
  14. package/components/fleet/GitRepoMetadataTab.vue +5 -0
  15. package/components/fleet/HelmOpAppCoConfigTab.vue +4 -0
  16. package/components/fleet/HelmOpMetadataTab.vue +5 -0
  17. package/components/form/FileSelector.vue +39 -1
  18. package/components/form/PrivateRegistry.constants.ts +7 -0
  19. package/components/form/PrivateRegistry.vue +253 -18
  20. package/components/form/SelectOrCreateAuthSecret.vue +140 -17
  21. package/components/form/__tests__/FileSelector.test.ts +23 -0
  22. package/components/form/__tests__/PrivateRegistry.test.ts +463 -73
  23. package/components/form/__tests__/SelectOrCreateAuthSecret.test.ts +122 -0
  24. package/components/formatter/EtcdSnapshotName.vue +73 -0
  25. package/components/nav/Header.vue +8 -1
  26. package/components/templates/default.vue +7 -0
  27. package/config/features.js +1 -0
  28. package/config/labels-annotations.js +2 -0
  29. package/config/product/manager.js +6 -0
  30. package/config/secret.ts +10 -0
  31. package/config/settings.ts +6 -2
  32. package/config/types.js +7 -0
  33. package/detail/provisioning.cattle.io.cluster.vue +79 -3
  34. package/dialog/RotateEncryptionKeyDialog.vue +33 -9
  35. package/dialog/__tests__/RotateEncryptionKeyDialog.test.ts +78 -0
  36. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +92 -0
  37. package/edit/__tests__/fleet.cattle.io.helmop.test.ts +101 -0
  38. package/edit/__tests__/management.cattle.io.setting.test.ts +2 -1
  39. package/edit/compliance.cattle.io.clusterscanprofile.vue +39 -41
  40. package/edit/fleet.cattle.io.gitrepo.vue +70 -16
  41. package/edit/fleet.cattle.io.helmop.vue +51 -5
  42. package/edit/helm.cattle.io.projecthelmchart.vue +1 -0
  43. package/edit/{management.cattle.io.setting.vue → management.cattle.io.setting/index.vue} +32 -9
  44. package/edit/management.cattle.io.setting/system-default-registry-pull-secrets.vue +81 -0
  45. package/edit/provisioning.cattle.io.cluster/SelectCredential.vue +3 -12
  46. package/edit/provisioning.cattle.io.cluster/__tests__/rke2.test.ts +18 -0
  47. package/edit/provisioning.cattle.io.cluster/rke2.vue +5 -1
  48. package/edit/provisioning.cattle.io.cluster/tabs/etcd/index.vue +0 -1
  49. package/edit/provisioning.cattle.io.cluster/tabs/registries/index.vue +14 -55
  50. package/models/__tests__/provisioning.cattle.io.cluster.test.ts +156 -0
  51. package/models/__tests__/secret.test.ts +68 -1
  52. package/models/management.cattle.io.cluster.js +21 -3
  53. package/models/pod.js +13 -2
  54. package/models/provisioning.cattle.io.cluster.js +59 -9
  55. package/models/rke.cattle.io.etcdsnapshot.js +17 -9
  56. package/models/secret.js +19 -0
  57. package/models/workload.js +12 -7
  58. package/package.json +1 -1
  59. package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +485 -107
  60. package/pages/c/_cluster/apps/charts/install.vue +114 -28
  61. package/pkg/require-asset.lib.js +25 -0
  62. package/pkg/vue.config.js +7 -0
  63. package/plugins/dashboard-store/__tests__/resource-class.test.ts +84 -0
  64. package/plugins/dashboard-store/getters.js +0 -1
  65. package/plugins/dashboard-store/resource-class.js +52 -12
  66. package/rancher-components/Form/TextArea/TextAreaAutoGrow.vue +30 -0
  67. package/rancher-components/Form/TextArea/__tests__/TextAreaAutoGrow.test.ts +95 -0
  68. package/rancher-components/RcButton/index.ts +1 -1
  69. package/rancher-components/RcDropdown/RcDropdownTrigger.vue +6 -1
  70. package/store/__tests__/features.test.ts +131 -0
  71. package/store/__tests__/growl.test.ts +374 -0
  72. package/store/__tests__/modal.test.ts +131 -0
  73. package/store/__tests__/slideInPanel.test.ts +88 -0
  74. package/store/__tests__/type-map.utils.test.ts +433 -0
  75. package/store/features.js +4 -0
  76. package/types/shell/index.d.ts +62 -0
  77. package/utils/__tests__/operation-cr.test.ts +34 -0
  78. package/utils/operation-cr.js +19 -0
  79. package/utils/require-asset.ts +7 -0
  80. package/utils/validators/__tests__/private-registry.test.ts +27 -15
  81. package/utils/validators/private-registry.ts +15 -4
@@ -1,24 +1,185 @@
1
1
  <script setup lang="ts">
2
- import { ref, watch } from 'vue';
2
+ import { ref, watch, onMounted, computed } from 'vue';
3
+ import { useStore } from 'vuex';
4
+ import { useI18n } from '@shell/composables/useI18n';
3
5
  import Banner from '@components/Banner/Banner.vue';
4
6
  import { Checkbox } from '@components/Form/Checkbox';
5
7
  import LabeledInput from '@components/Form/LabeledInput/LabeledInput.vue';
8
+ import SelectOrCreateAuthSecret from '@shell/components/form/SelectOrCreateAuthSecret.vue';
9
+ import { MANAGEMENT } from '@shell/config/types';
10
+ import { SETTING } from '@shell/config/settings';
11
+ import { PRIVATE_REGISTRY_CONTEXT } from '@shell/components/form/PrivateRegistry.constants';
12
+ import type { PrivateRegistryContext } from '@shell/components/form/PrivateRegistry.constants';
6
13
 
7
- const props = defineProps<{
14
+ const props = withDefaults(defineProps<{
8
15
  value?: string | null;
9
16
  enabled?: boolean;
10
17
  mode?: string;
11
18
  rules?: Function[];
12
19
  checkboxTestId?: string;
13
20
  inputTestId?: string;
14
- }>();
21
+ pullSecret?: string;
22
+ registerBeforeHook?: Function;
23
+ context?: PrivateRegistryContext;
24
+ defaultRegistry?: string;
25
+ namespace?: string;
26
+ inStore?: string;
27
+ showPullSecrets?: boolean;
28
+ noneLabel?: string | null;
29
+ skipPullSecrets?: boolean;
30
+ repoDefaultPullSecrets?: string[];
31
+ existingValuesPullSecrets?: string[];
32
+ }>(), {
33
+ value: undefined,
34
+ enabled: false,
35
+ mode: 'edit',
36
+ rules: () => [],
37
+ checkboxTestId: undefined,
38
+ inputTestId: undefined,
39
+ pullSecret: undefined,
40
+ registerBeforeHook: undefined,
41
+ context: PRIVATE_REGISTRY_CONTEXT.PROVISIONING,
42
+ defaultRegistry: undefined,
43
+ namespace: 'fleet-default',
44
+ inStore: 'management',
45
+ showPullSecrets: true,
46
+ noneLabel: undefined,
47
+ skipPullSecrets: false,
48
+ repoDefaultPullSecrets: () => [],
49
+ existingValuesPullSecrets: () => [],
50
+ });
15
51
 
16
52
  const emit = defineEmits<{
17
- 'update:value': [val: string | null];
53
+ 'update:value': [val: string | undefined];
18
54
  'update:enabled': [val: boolean];
55
+ 'update:pullSecret': [val: string | undefined];
56
+ 'update:skipPullSecrets': [val: boolean];
19
57
  }>();
20
58
 
59
+ const store = useStore();
60
+ const { t } = useI18n(store);
61
+
21
62
  const showInput = ref(!!props.value);
63
+ const globalRegistry = ref('');
64
+ const defaultPullSecrets = ref<string[]>([]);
65
+ const localSkipPullSecrets = ref(props.skipPullSecrets);
66
+
67
+ const isCharts = computed(() => props.context === PRIVATE_REGISTRY_CONTEXT.CHARTS);
68
+ const isImporting = computed(() => props.context === PRIVATE_REGISTRY_CONTEXT.IMPORTING);
69
+
70
+ const descriptionKey = computed(() => {
71
+ if (isCharts.value) {
72
+ return 'catalog.chart.registry.tooltip';
73
+ }
74
+ if (isImporting.value) {
75
+ return 'cluster.privateRegistry.importedDescription';
76
+ }
77
+
78
+ return 'cluster.privateRegistry.description';
79
+ });
80
+
81
+ const checkboxLabelKey = computed(() => {
82
+ if (isCharts.value) {
83
+ return 'catalog.chart.registry.custom.checkBoxLabel';
84
+ }
85
+
86
+ return 'cluster.privateRegistry.label';
87
+ });
88
+
89
+ const checkboxTooltipKey = computed(() => {
90
+ if (isCharts.value) {
91
+ return 'catalog.chart.registry.tooltip';
92
+ }
93
+
94
+ return undefined;
95
+ });
96
+
97
+ onMounted(() => {
98
+ if (props.defaultRegistry) {
99
+ globalRegistry.value = props.defaultRegistry;
100
+ } else {
101
+ const registrySetting = store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.SYSTEM_DEFAULT_REGISTRY);
102
+
103
+ globalRegistry.value = registrySetting?.value || registrySetting?.defaultValue;
104
+ }
105
+
106
+ const pullSecretsSetting = store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.SYSTEM_DEFAULT_REGISTRY_PULL_SECRETS);
107
+
108
+ if (props.repoDefaultPullSecrets?.length) {
109
+ defaultPullSecrets.value = props.repoDefaultPullSecrets;
110
+ } else if (pullSecretsSetting?.value ) {
111
+ defaultPullSecrets.value = pullSecretsSetting?.value.split(',').map((s: string) => s.trim());
112
+ }
113
+ });
114
+
115
+ const hasMultipleDefaultSecrets = computed(() => {
116
+ return defaultPullSecrets.value?.length > 1;
117
+ });
118
+
119
+ /**
120
+ * On upgrade, if the chart values already contain multiple imagePullSecrets,
121
+ * the dropdown cannot represent them. Show a banner instead and let the
122
+ * user edit the values YAML directly.
123
+ */
124
+ const hasMultipleExistingPullSecrets = computed(() => {
125
+ return (props.existingValuesPullSecrets?.length ?? 0) > 1;
126
+ });
127
+
128
+ /**
129
+ * Format a list of names with commas and "and" before the last item.
130
+ * e.g. ["a"] => "<code>a</code>"
131
+ * ["a", "b"] => "<code>a</code> and <code>b</code>"
132
+ * ["a", "b", "c"] => "<code>a</code>, <code>b</code>, and <code>c</code>"
133
+ */
134
+ function formatSecretList(names: string[]): string {
135
+ const wrapped = names.map((n) => `<code>${ n }</code>`);
136
+
137
+ const and = t('generic.and');
138
+
139
+ if (wrapped.length <= 1) {
140
+ return wrapped[0] || '';
141
+ }
142
+ if (wrapped.length === 2) {
143
+ return `${ wrapped[0] }${ and }${ wrapped[1] }`;
144
+ }
145
+
146
+ return `${ wrapped.slice(0, -1).join(', ') },${ and }${ wrapped[wrapped.length - 1] }`;
147
+ }
148
+
149
+ /**
150
+ * If there is exactly one default pull secret configured, we can show that it is the default in the secret dropdown.
151
+ * If there is more than one, UI can't be certain which secret will be used
152
+ * so a banner listing all secrets is shown instead.
153
+ */
154
+ const defaultPullSecretLabel = computed(() => {
155
+ if (props.noneLabel) {
156
+ return props.noneLabel;
157
+ }
158
+ if (!defaultPullSecrets.value?.length) {
159
+ return null;
160
+ }
161
+ if (defaultPullSecrets.value?.length === 1) {
162
+ return t('catalog.chart.registry.pullSecret.defaultLabel', { name: defaultPullSecrets.value[0] });
163
+ }
164
+
165
+ return t('catalog.chart.registry.pullSecret.defaultLabelGeneric');
166
+ });
167
+
168
+ const existingValuesBannerHtml = computed(() => {
169
+ if (!props.existingValuesPullSecrets?.length) {
170
+ return '';
171
+ }
172
+
173
+ return t('catalog.chart.registry.pullSecret.existingValuesBanner', { secrets: formatSecretList(props.existingValuesPullSecrets) }, true);
174
+ });
175
+
176
+ const defaultSecretsBannerHtml = computed(() => {
177
+ if (!defaultPullSecrets.value?.length) {
178
+ return '';
179
+ }
180
+
181
+ return t('catalog.chart.registry.pullSecret.defaultSecretsBanner', { secrets: formatSecretList(defaultPullSecrets.value) }, true);
182
+ });
22
183
 
23
184
  watch(() => props.enabled, (neu) => {
24
185
  if (typeof neu === 'boolean' && neu !== showInput.value) {
@@ -31,7 +192,8 @@ watch(showInput, (neu, old) => {
31
192
  emit('update:enabled', neu);
32
193
  }
33
194
  if (!neu && old && props.value) {
34
- emit('update:value', null);
195
+ emit('update:value', undefined);
196
+ emit('update:pullSecret', undefined);
35
197
  }
36
198
  });
37
199
 
@@ -40,30 +202,103 @@ watch(() => props.value, (neu) => {
40
202
  showInput.value = true;
41
203
  }
42
204
  });
205
+
206
+ watch(() => props.pullSecret, (neu, old) => {
207
+ if (neu && !props.value) {
208
+ emit('update:value', globalRegistry.value);
209
+ }
210
+ if (!neu && old && props.value === globalRegistry.value) {
211
+ emit('update:value', undefined);
212
+ }
213
+ });
214
+
215
+ watch(localSkipPullSecrets, (neu) => {
216
+ emit('update:skipPullSecrets', neu);
217
+ if (neu) {
218
+ emit('update:pullSecret', undefined);
219
+ }
220
+ });
221
+
222
+ watch(() => props.skipPullSecrets, (neu) => {
223
+ if (neu !== localSkipPullSecrets.value) {
224
+ localSkipPullSecrets.value = neu;
225
+ }
226
+ });
227
+
43
228
  </script>
44
229
 
45
230
  <template>
46
231
  <Banner
47
232
  color="info"
48
233
  class="mt-0"
49
- label-key="cluster.privateRegistry.importedDescription"
234
+ :label-key="descriptionKey"
50
235
  />
51
236
  <Checkbox
52
237
  v-model:value="showInput"
53
238
  class="mb-20"
54
239
  :mode="mode"
55
- :label="t('cluster.privateRegistry.label')"
240
+ :label="t(checkboxLabelKey)"
241
+ :tooltip="checkboxTooltipKey ? t(checkboxTooltipKey) : undefined"
56
242
  :data-testid="checkboxTestId"
57
243
  />
58
- <LabeledInput
59
- v-if="showInput"
60
- :value="value as string"
61
- :mode="mode"
62
- :rules="rules"
63
- :required="true"
64
- label-key="catalog.chart.registry.custom.inputLabel"
65
- :data-testid="inputTestId"
66
- :placeholder="t('catalog.chart.registry.custom.placeholder')"
67
- @update:value="(val) => emit('update:value', val)"
68
- />
244
+ <template v-if="showInput">
245
+ <div class="row">
246
+ <div class="col span-6">
247
+ <LabeledInput
248
+ :value="value || globalRegistry"
249
+ :mode="mode"
250
+ :rules="rules"
251
+ :required="!globalRegistry"
252
+ label-key="catalog.chart.registry.custom.inputLabel"
253
+ :data-testid="inputTestId"
254
+ :placeholder="t('catalog.chart.registry.custom.placeholder')"
255
+ @update:value="(val) => emit('update:value', val)"
256
+ />
257
+ </div>
258
+ </div>
259
+ <template v-if="showPullSecrets">
260
+ <Checkbox
261
+ v-if="isCharts"
262
+ v-model:value="localSkipPullSecrets"
263
+ class="mb-10 mt-10"
264
+ :mode="mode"
265
+ :label="t('catalog.chart.registry.pullSecret.skipOption')"
266
+ data-testid="registry-skip-pull-secrets-checkbox"
267
+ />
268
+ <Banner
269
+ v-if="hasMultipleExistingPullSecrets && !localSkipPullSecrets"
270
+ color="info"
271
+ >
272
+ <span v-clean-html="existingValuesBannerHtml" />
273
+ </Banner>
274
+ <Banner
275
+ v-if="!hasMultipleExistingPullSecrets && hasMultipleDefaultSecrets && !localSkipPullSecrets"
276
+ color="info"
277
+ >
278
+ <span v-clean-html="defaultSecretsBannerHtml" />
279
+ </Banner>
280
+ <div :class="{'col span-6': !isCharts}">
281
+ <SelectOrCreateAuthSecret
282
+ v-if="!localSkipPullSecrets && !hasMultipleExistingPullSecrets"
283
+ :value="pullSecret"
284
+ :namespace="namespace"
285
+ :allow-rke="!isCharts"
286
+ :vertical="!isCharts"
287
+ :in-store="inStore"
288
+ limit-to-namespace
289
+ fixed-image-pull-secret
290
+ :none-label="defaultPullSecretLabel"
291
+ :image-pull-secret-docker-json-url-config="value || globalRegistry"
292
+ :register-before-hook="registerBeforeHook"
293
+ @update:value="(val) => emit('update:pullSecret', val)"
294
+ />
295
+ </div>
296
+ </template>
297
+ </template>
69
298
  </template>
299
+
300
+ <style lang="scss" scoped>
301
+ :deep(.banner__content) {
302
+ display: block;
303
+ }
304
+ </style>
@@ -4,8 +4,9 @@ import { Banner } from '@components/Banner';
4
4
  import { LabeledInput } from '@components/Form/LabeledInput';
5
5
  import LabeledSelect from '@shell/components/form/LabeledSelect';
6
6
  import SSHKnownHosts from '@shell/components/form/SSHKnownHosts';
7
+ import FileSelector from '@shell/components/form/FileSelector';
7
8
  import { AUTH_TYPE, NORMAN, SECRET } from '@shell/config/types';
8
- import { SECRET_TYPES } from '@shell/config/secret';
9
+ import { SECRET_TYPES, GITHUB_APP_SECRET_KEYS } from '@shell/config/secret';
9
10
  import { base64Encode } from '@shell/utils/crypto';
10
11
  import { addObjects, insertAt } from '@shell/utils/array';
11
12
  import { sortBy } from '@shell/utils/sort';
@@ -15,6 +16,16 @@ import {
15
16
  PaginationParamFilter,
16
17
  } from '@shell/types/store/pagination.types';
17
18
 
19
+ // Auth types for which this component renders inline fields and can create a secret/credential
20
+ const CREATABLE_AUTH_TYPES = [
21
+ AUTH_TYPE._SSH,
22
+ AUTH_TYPE._BASIC,
23
+ AUTH_TYPE._S3,
24
+ AUTH_TYPE._RKE,
25
+ AUTH_TYPE._IMAGE_PULL_SECRET,
26
+ AUTH_TYPE._GITHUB_APP,
27
+ ];
28
+
18
29
  export default {
19
30
  name: 'SelectOrCreateAuthSecret',
20
31
 
@@ -25,6 +36,7 @@ export default {
25
36
  LabeledInput,
26
37
  LabeledSelect,
27
38
  SSHKnownHosts,
39
+ FileSelector,
28
40
  },
29
41
 
30
42
  props: {
@@ -101,6 +113,11 @@ export default {
101
113
  default: false,
102
114
  },
103
115
 
116
+ allowGithubApp: {
117
+ type: Boolean,
118
+ default: false,
119
+ },
120
+
104
121
  registerBeforeHook: {
105
122
  type: Function,
106
123
  required: true,
@@ -195,10 +212,16 @@ export default {
195
212
  type: Boolean,
196
213
  default: false,
197
214
  },
215
+
216
+ // Overwrite the default label for "None" option ('generic.none')
217
+ noneLabel: {
218
+ type: [String, null],
219
+ default: null
220
+ }
198
221
  },
199
222
 
200
223
  async fetch() {
201
- if ( (this.allowSsh || this.allowBasic || this.allowRke || this.fixedImagePullSecret) && this.$store.getters[`${ this.inStore }/schemaFor`](SECRET) ) {
224
+ if ( (this.allowSsh || this.allowBasic || this.allowRke || this.allowGithubApp || this.fixedImagePullSecret) && this.$store.getters[`${ this.inStore }/schemaFor`](SECRET) ) {
202
225
  if (this.$store.getters[`${ this.inStore }/paginationEnabled`](SECRET)) {
203
226
  // Filter results via api (because we shouldn't be fetching them all...)
204
227
  this.filteredSecrets = await this.filterSecretsByApi();
@@ -248,13 +271,19 @@ export default {
248
271
  publicKey: '',
249
272
  privateKey: '',
250
273
  sshKnownHosts: '',
251
- uniqueId: new Date().getTime(), // Allows form state to be individually tracked if the form is in a list
274
+
275
+ githubAppId: '',
276
+ githubAppInstallationId: '',
277
+ githubAppPrivateKey: '',
278
+
279
+ uniqueId: new Date().getTime(), // Allows form state to be individually tracked if the form is in a list
252
280
 
253
281
  SSH: AUTH_TYPE._SSH,
254
282
  BASIC: AUTH_TYPE._BASIC,
255
283
  IMAGE_PULL_SECRET: AUTH_TYPE._IMAGE_PULL_SECRET,
256
284
  S3: AUTH_TYPE._S3,
257
285
  RKE: AUTH_TYPE._RKE,
286
+ GITHUB_APP: AUTH_TYPE._GITHUB_APP,
258
287
  };
259
288
  },
260
289
 
@@ -280,6 +309,12 @@ export default {
280
309
  types.push(SECRET_TYPES.RKE_AUTH_CONFIG);
281
310
  }
282
311
 
312
+ // GitHub App secrets are stored as Opaque; they're narrowed down to actual
313
+ // GitHub App secrets via the data keys in `options`.
314
+ if ( this.allowGithubApp ) {
315
+ types.push(SECRET_TYPES.OPAQUE);
316
+ }
317
+
283
318
  return types;
284
319
  },
285
320
 
@@ -311,6 +346,12 @@ export default {
311
346
  filteredSecrets = this.filteredSecrets;
312
347
  }
313
348
 
349
+ // GitHub App secrets are fetched as Opaque (broad). Keep only the Opaque
350
+ // secrets that actually hold the GitHub App data keys.
351
+ if (this.allowGithubApp) {
352
+ filteredSecrets = filteredSecrets.filter((x) => x._type !== SECRET_TYPES.OPAQUE || x.isGithubApp);
353
+ }
354
+
314
355
  let out = filteredSecrets.map((x) => {
315
356
  const {
316
357
  dataPreview, subTypeDisplay, metadata, id
@@ -370,12 +411,12 @@ export default {
370
411
  }
371
412
  if ( this.allowNone ) {
372
413
  out.unshift({
373
- label: this.t('generic.none'),
414
+ label: this.noneLabel || this.t('generic.none'),
374
415
  value: AUTH_TYPE._NONE,
375
416
  });
376
417
  }
377
418
 
378
- if (this.allowSsh || this.allowS3 || this.allowBasic || this.allowRke || this.fixedImagePullSecret) {
419
+ if (this.allowSsh || this.allowS3 || this.allowBasic || this.allowRke || this.allowGithubApp || this.fixedImagePullSecret) {
379
420
  out.unshift({
380
421
  label: 'divider',
381
422
  disabled: true,
@@ -383,6 +424,14 @@ export default {
383
424
  });
384
425
  }
385
426
 
427
+ if ( this.allowGithubApp ) {
428
+ out.unshift({
429
+ label: this.t('selectOrCreateAuthSecret.createGithubApp'),
430
+ value: AUTH_TYPE._GITHUB_APP,
431
+ kind: 'highlighted'
432
+ });
433
+ }
434
+
386
435
  if ( this.allowSsh ) {
387
436
  out.unshift({
388
437
  label: this.t('selectOrCreateAuthSecret.createSsh'),
@@ -453,12 +502,15 @@ export default {
453
502
  },
454
503
 
455
504
  watch: {
456
- selected: 'update',
457
- publicKey: 'updateKeyVal',
458
- privateKey: 'updateKeyVal',
459
- sshKnownHosts: 'updateKeyVal',
460
- preSelect: 'updateSelectedFromValue',
461
- value: 'updateSelectedFromValue',
505
+ selected: 'update',
506
+ publicKey: 'updateKeyVal',
507
+ privateKey: 'updateKeyVal',
508
+ sshKnownHosts: 'updateKeyVal',
509
+ githubAppId: 'updateKeyVal',
510
+ githubAppInstallationId: 'updateKeyVal',
511
+ githubAppPrivateKey: 'updateKeyVal',
512
+ preSelect: 'updateSelectedFromValue',
513
+ value: 'updateSelectedFromValue',
462
514
 
463
515
  async namespace(ns) {
464
516
  if (ns && !this.selected.startsWith(`${ ns }/`)) {
@@ -541,10 +593,13 @@ export default {
541
593
  },
542
594
 
543
595
  updateKeyVal() {
544
- if ( ![AUTH_TYPE._SSH, AUTH_TYPE._BASIC, AUTH_TYPE._S3, AUTH_TYPE._RKE, AUTH_TYPE._IMAGE_PULL_SECRET].includes(this.selected) ) {
596
+ if ( !CREATABLE_AUTH_TYPES.includes(this.selected) ) {
545
597
  this.privateKey = '';
546
598
  this.publicKey = '';
547
599
  this.sshKnownHosts = '';
600
+ this.githubAppId = '';
601
+ this.githubAppInstallationId = '';
602
+ this.githubAppPrivateKey = '';
548
603
  }
549
604
 
550
605
  const value = {
@@ -557,11 +612,17 @@ export default {
557
612
  value.sshKnownHosts = this.sshKnownHosts;
558
613
  }
559
614
 
615
+ if (this.selected === AUTH_TYPE._GITHUB_APP) {
616
+ value.githubAppId = this.githubAppId;
617
+ value.githubAppInstallationId = this.githubAppInstallationId;
618
+ value.githubAppPrivateKey = this.githubAppPrivateKey;
619
+ }
620
+
560
621
  this.$emit('inputauthval', value);
561
622
  },
562
623
 
563
624
  update() {
564
- if ( (!this.selected || [AUTH_TYPE._SSH, AUTH_TYPE._BASIC, AUTH_TYPE._S3, AUTH_TYPE._RKE, AUTH_TYPE._NONE, AUTH_TYPE._IMAGE_PULL_SECRET].includes(this.selected))) {
625
+ if ( (!this.selected || this.selected === AUTH_TYPE._NONE || CREATABLE_AUTH_TYPES.includes(this.selected))) {
565
626
  this.$emit('update:value', null);
566
627
  } else if ( this.selected.includes(':')) {
567
628
  // Cloud creds
@@ -585,7 +646,7 @@ export default {
585
646
  },
586
647
 
587
648
  async doCreate() {
588
- if ( ![AUTH_TYPE._SSH, AUTH_TYPE._BASIC, AUTH_TYPE._S3, AUTH_TYPE._RKE, AUTH_TYPE._IMAGE_PULL_SECRET].includes(this.selected) || this.delegateCreateToParent ) {
649
+ if ( !CREATABLE_AUTH_TYPES.includes(this.selected) || this.delegateCreateToParent ) {
589
650
  return;
590
651
  }
591
652
 
@@ -636,6 +697,14 @@ export default {
636
697
  // Set the 'auth' key to be the base64 of the username and password concatenated with a ':' character
637
698
  secret.data = { auth: base64Encode(`${ this.publicKey }:${ this.privateKey }`) };
638
699
  break;
700
+ case AUTH_TYPE._GITHUB_APP:
701
+ type = SECRET_TYPES.OPAQUE;
702
+ secret.data = {
703
+ [GITHUB_APP_SECRET_KEYS.APP_ID]: base64Encode(this.githubAppId),
704
+ [GITHUB_APP_SECRET_KEYS.INSTALLATION_ID]: base64Encode(this.githubAppInstallationId),
705
+ [GITHUB_APP_SECRET_KEYS.PRIVATE_KEY]: base64Encode(this.githubAppPrivateKey),
706
+ };
707
+ break;
639
708
  default:
640
709
  throw new Error('Unknown type');
641
710
  }
@@ -655,8 +724,15 @@ export default {
655
724
  secret.data.known_hosts = base64Encode(this.sshKnownHosts || '');
656
725
  }
657
726
 
727
+ // Components passing imagePullSecretDockerJsonUrlConfig are responsible for validating that a valid hostname or URL is provided
658
728
  if (this.selected === AUTH_TYPE._IMAGE_PULL_SECRET && this.imagePullSecretDockerJsonUrlConfig) {
659
- const registryHost = this.imagePullSecretDockerJsonUrlConfig ? new URL(this.imagePullSecretDockerJsonUrlConfig).host : '';
729
+ let registryHost;
730
+
731
+ try {
732
+ registryHost = new URL(this.imagePullSecretDockerJsonUrlConfig).host;
733
+ } catch {
734
+ registryHost = this.imagePullSecretDockerJsonUrlConfig;
735
+ }
660
736
 
661
737
  const config = {
662
738
  auths: {
@@ -672,13 +748,14 @@ export default {
672
748
  }
673
749
  }
674
750
  }
675
-
676
751
  await secret.save();
677
752
 
678
753
  await this.$nextTick(() => {
679
754
  this.selected = secret.id;
680
755
  });
681
756
 
757
+ this.update();
758
+
682
759
  return secret;
683
760
  },
684
761
  },
@@ -780,11 +857,57 @@ export default {
780
857
  </div>
781
858
  </template>
782
859
  </div>
860
+ <div
861
+ v-if="selected === GITHUB_APP"
862
+ class="mt-20"
863
+ :class="{'row': !vertical}"
864
+ >
865
+ <div :class="vertical ? 'mt-20' : 'col span-3'">
866
+ <LabeledInput
867
+ v-model:value="githubAppId"
868
+ data-testid="auth-secret-github-app-id"
869
+ :mode="mode"
870
+ label-key="selectOrCreateAuthSecret.githubApp.appId"
871
+ />
872
+ </div>
873
+ <div :class="vertical ? 'mt-20' : 'col span-3'">
874
+ <LabeledInput
875
+ v-model:value="githubAppInstallationId"
876
+ data-testid="auth-secret-github-app-installation-id"
877
+ :mode="mode"
878
+ label-key="selectOrCreateAuthSecret.githubApp.installationId"
879
+ />
880
+ </div>
881
+ <div :class="vertical ? 'mt-20' : 'col span-6 gap'">
882
+ <LabeledInput
883
+ v-model:value="githubAppPrivateKey"
884
+ data-testid="auth-secret-github-app-private-key"
885
+ :mode="mode"
886
+ type="multiline"
887
+ :max-height="1000"
888
+ :resize-on-value-change-and-resize-window="true"
889
+ label-key="selectOrCreateAuthSecret.githubApp.privateKey"
890
+ />
891
+ <FileSelector
892
+ :as-rc-button="true"
893
+ :mode="mode"
894
+ :label="t('generic.readFromFile')"
895
+ data-testid="auth-secret-github-app-private-key-file"
896
+ @selected="githubAppPrivateKey = $event"
897
+ />
898
+ </div>
899
+ </div>
783
900
  </div>
784
901
  </template>
785
902
 
786
- <style lang="scss">
903
+ <style scoped lang="scss">
787
904
  .select-or-create-auth-secret div.labeled-select {
788
905
  min-height: $input-height;
789
906
  }
907
+
908
+ .gap {
909
+ display: flex;
910
+ flex-flow: row wrap;
911
+ gap: var(--gap);
912
+ }
790
913
  </style>
@@ -30,6 +30,29 @@ describe('component: FileSelector', () => {
30
30
  expect(uploadButton.exists()).toBeTruthy();
31
31
  });
32
32
 
33
+ it('should render a small, secondary RcButton when asRcButton is set', () => {
34
+ wrapper = mount(FileSelector, {
35
+ props: { label: 'upload', asRcButton: true },
36
+ global: { mocks: {} },
37
+ });
38
+
39
+ const rcButton = wrapper.findComponent({ name: 'RcButton' });
40
+
41
+ expect(rcButton.exists()).toBe(true);
42
+ expect(rcButton.props('variant')).toBe('secondary');
43
+ expect(rcButton.props('size')).toBe('small');
44
+ });
45
+
46
+ it('should render a plain button by default (asRcButton not set)', () => {
47
+ wrapper = mount(FileSelector, {
48
+ props: { label: 'upload' },
49
+ global: { mocks: {} },
50
+ });
51
+
52
+ expect(wrapper.findComponent({ name: 'RcButton' }).exists()).toBe(false);
53
+ expect(wrapper.find('[data-testid="file-selector__uploader-button"]').exists()).toBe(true);
54
+ });
55
+
33
56
  it('should succeed when loading an image', async() => {
34
57
  wrapper = mount(FileSelector, {
35
58
  props: { label: 'upload', accept: 'image/jpeg,image/png,image/svg+xml' },