diff --git a/.github/workflows/busola-backend-build.yml b/.github/workflows/busola-backend-build.yml index e422c264a8..35186b5bae 100644 --- a/.github/workflows/busola-backend-build.yml +++ b/.github/workflows/busola-backend-build.yml @@ -27,4 +27,4 @@ jobs: dockerfile: Dockerfile context: backend export-tags: true - + tags: latest diff --git a/.github/workflows/busola-local-build.yml b/.github/workflows/busola-local-build.yml index 38614b062e..aba96241fb 100644 --- a/.github/workflows/busola-local-build.yml +++ b/.github/workflows/busola-local-build.yml @@ -37,3 +37,4 @@ jobs: dockerfile: Dockerfile.local context: . export-tags: true + tags: latest diff --git a/.github/workflows/busola-web-build.yml b/.github/workflows/busola-web-build.yml index 6b0af58b84..e7ffb6b249 100644 --- a/.github/workflows/busola-web-build.yml +++ b/.github/workflows/busola-web-build.yml @@ -33,4 +33,4 @@ jobs: dockerfile: Dockerfile context: . export-tags: true - + tags: latest diff --git a/docs/extensibility/60-form-widgets.md b/docs/extensibility/60-form-widgets.md index 7f5184a0e0..1b5ebdeb8d 100644 --- a/docs/extensibility/60-form-widgets.md +++ b/docs/extensibility/60-form-widgets.md @@ -4,6 +4,7 @@ You can use form widgets in the create and/or edit pages in the user interface c - [Simple widgets](#simple-widgets) that represent a single scalar value - [`Text`](#text) + - [`Number`](#number) - [`Switch`](#switch) - [`Name`](#name) - [`CodeEditor`](#codeeditor) @@ -73,6 +74,34 @@ See the following examples: Example of a dropdown text widget with a tooltip Example of a dropdown text widget +### `Number` + +The `Number` widgets render a field as a number field. They are used by default for all number and integer values. + +| Parameter | Required | Type | Description | +| ----------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **description** | No | string | A string displayed in a tooltip when you hover over a question mark icon, next to the input's label. The default value is taken from the CustomResourceDefintion (CRD). | +| **disableOnEdit** | No | boolean | Disables a number field in edit mode, defaults to `false`. | + +See the following examples: + +```yaml +- path: spec.capacity + name: spec.capacity + widget: Number +``` + +Example of a number widget + +```yaml +- path: spec.capacity + name: spec.capacity + widget: Number + disableOnEdit: true +``` + +Example of a number widget + ### `Switch` The `Switch` widgets render a switch button that is used to control boolean values. diff --git a/docs/extensibility/assets/form-widgets/Number.png b/docs/extensibility/assets/form-widgets/Number.png new file mode 100644 index 0000000000..4b6d0b47c2 Binary files /dev/null and b/docs/extensibility/assets/form-widgets/Number.png differ diff --git a/docs/extensibility/assets/form-widgets/Number2.png b/docs/extensibility/assets/form-widgets/Number2.png new file mode 100644 index 0000000000..4003b66f54 Binary files /dev/null and b/docs/extensibility/assets/form-widgets/Number2.png differ diff --git a/public/schemas/schema-form.yaml b/public/schemas/schema-form.yaml index 08310f235e..32df824e17 100644 --- a/public/schemas/schema-form.yaml +++ b/public/schemas/schema-form.yaml @@ -326,3 +326,18 @@ $widgets: type: boolean description: a boolean which specifies if field is disabled on edit. default: false + Number: + if: + properties: + widget: + const: Number + then: + properties: + description: + type: string + description: A string displayed in a tooltip when you hover over a question mark icon, next to the input's label. The default value is taken from the CustomResourceDefintion (CRD). + disableOnEdit: + type: boolean + description: a boolean which specifies if field is disabled on edit. + default: false + diff --git a/src/components/Clusters/components/AddClusterDialog.js b/src/components/Clusters/components/AddClusterDialog.js index 19bf84562d..bf23a5f039 100644 --- a/src/components/Clusters/components/AddClusterDialog.js +++ b/src/components/Clusters/components/AddClusterDialog.js @@ -27,7 +27,7 @@ export function AddClusterDialog() { return ( setShowWizard(false)} > diff --git a/src/components/Clusters/components/AddClusterWizard.js b/src/components/Clusters/components/AddClusterWizard.js index 7d6578b69b..eac0818225 100644 --- a/src/components/Clusters/components/AddClusterWizard.js +++ b/src/components/Clusters/components/AddClusterWizard.js @@ -49,11 +49,13 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { const setIsFormOpen = useSetRecoilState(isFormOpenState); useEffect(() => { - const contentContainer = document - .getElementsByTagName('ui5-wizard')[0] - ?.shadowRoot?.querySelectorAll('.ui5-wiz-content-item-wrapper')[ - selected - 1 - ]; + const wizard = document.getElementsByTagName('ui5-wizard')[0]; + const wizardContent = wizard?.shadowRoot?.querySelector('.ui5-wiz-content'); + + const contentContainer = wizardContent?.querySelectorAll( + '.ui5-wiz-content-item-wrapper', + )[selected - 1]; + if (contentContainer) { contentContainer.style['background-color'] = 'transparent'; contentContainer.style['padding'] = '0'; @@ -166,7 +168,7 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { firstStep={true} onCancel={() => setShowWizard(false)} validation={!kubeconfig} - className="cluster-wizard__buttons" + className="cluster-wizard__buttons__sticky" /> @@ -197,7 +199,7 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { setSelected={setSelected} onCancel={() => setShowWizard(false)} validation={!authValid} - className="cluster-wizard__buttons" + className="cluster-wizard__buttons__absolute" /> )} @@ -217,14 +219,14 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { data-step={!hasAuth || !hasOneContext ? '3' : '2'} >
- + <Title level="H5" style={spacing.sapUiSmallMarginBottom}> {t('clusters.storage.choose-storage.label')} <> <Button id="storageDescriptionOpener" icon="hint" design="Transparent" - style={spacing.sapUiTinyMargin} + style={spacing.sapUiTinyMarginBegin} onClick={() => setShowTitleDescription(true)} /> {createPortal( @@ -248,7 +250,7 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { setSelected={setSelected} validation={!storage} onCancel={() => setShowWizard(false)} - className="cluster-wizard__buttons" + className="cluster-wizard__buttons__absolute" /> </div> </WizardStep> @@ -269,7 +271,6 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { <ClusterPreview storage={storage} kubeconfig={kubeconfig} - token={hasAuth ? hasKubeconfigAuth(kubeconfig) : null} setSelected={setSelected} hasAuth={hasAuth} /> @@ -284,7 +285,7 @@ export function AddClusterWizard({ kubeconfig, setKubeconfig, config }) { setIsFormOpen({ formOpen: false }); }} validation={!storage} - className="cluster-wizard__buttons" + className="cluster-wizard__buttons__sticky" /> </WizardStep> </Wizard> diff --git a/src/components/Clusters/components/AddClusterWizard.scss b/src/components/Clusters/components/AddClusterWizard.scss index 1a294f9337..5e5f0cc198 100644 --- a/src/components/Clusters/components/AddClusterWizard.scss +++ b/src/components/Clusters/components/AddClusterWizard.scss @@ -1,3 +1,7 @@ +.add-cluster-wizard-dialog::part(content) { + height: 90vh; +} + .add-cluster { &__content-container { background-color: var(--sapGroup_TitleBackground); @@ -11,20 +15,17 @@ } ui5-wizard { - height: 90vh; - min-width: 80vw; + height: 100%; + width: 75vw; .cluster-wizard { - &__buttons { - position: absolute; + @mixin cluster-wizard-buttons-style { + z-index: 100; bottom: 1rem; - left: 0; display: flex; justify-content: flex-end; box-sizing: border-box; - width: calc(100% - 20px); - margin: 0 10px; - padding: 7px; + padding: 0.5rem; border: 1px solid var(--sapGroup_TitleBorderColor); border-radius: var(--sapElement_BorderCornerRadius); background-color: var(--sapGroup_TitleBackground); @@ -34,6 +35,23 @@ ui5-wizard { color-mix(in srgb, var(--sapContent_ShadowColor) 16%, transparent); } + &__buttons { + &__sticky { + position: sticky; + width: calc(100% + 32px); + margin: 0 -16px; + @include cluster-wizard-buttons-style; + } + + &__absolute { + position: absolute; + left: 0; + width: calc(100% - 32px); + margin: 0 16px; + @include cluster-wizard-buttons-style; + } + } + &__token-info, &__storage-preference { color: var(--sapContent_LabelColor); @@ -44,7 +62,6 @@ ui5-wizard { display: flex; flex-flow: column; overflow: auto; - height: calc(90vh - 8rem); .cluster-wizard__auth-form { height: unset; @@ -53,7 +70,7 @@ ui5-wizard { } .resource-form { - padding: 1rem 0; + padding: 0; .form-field { justify-content: start; diff --git a/src/components/Clusters/components/AuthForm.js b/src/components/Clusters/components/AuthForm.js index dfa9a613b2..eebcecc9c8 100644 --- a/src/components/Clusters/components/AuthForm.js +++ b/src/components/Clusters/components/AuthForm.js @@ -9,10 +9,7 @@ import * as Inputs from 'shared/ResourceForm/inputs'; import { getUser, getUserIndex } from '../shared'; import { spacing } from '@ui5/webcomponents-react-base'; - -export const AUTH_FORM_TOKEN = 'Token'; -export const AUTH_FORM_OIDC = 'OIDC'; -export const DEFAULT_SCOPE_VALUE = 'openid'; +import { TextArrayInput } from 'shared/ResourceForm/fields'; const OIDCform = ({ resource, setResource, ...props }) => { const { t } = useTranslation(); @@ -57,11 +54,11 @@ const OIDCform = ({ resource, setResource, ...props }) => { input={Inputs.Text} aria-label="client-secret" /> - <ResourceForm.FormField + <TextArrayInput required - propertyPath="$.scope" - label={t('clusters.wizard.auth.scopes')} - input={Inputs.Text} + defaultOpen + propertyPath="$.scopes" + title={t('clusters.wizard.auth.scopes')} aria-label="scopes" /> </ResourceForm.Wrapper> @@ -147,7 +144,11 @@ export function AuthForm({ <ResourceForm.FormField label={t('clusters.wizard.auth.using-oidc')} input={() => ( - <Switch checked={useOidc} onChange={switchAuthVariant} /> + <Switch + style={spacing.sapUiTinyMarginTop} + checked={useOidc} + onChange={switchAuthVariant} + /> )} className="oidc-switch" /> diff --git a/src/components/Clusters/components/ChooseStorage.js b/src/components/Clusters/components/ChooseStorage.js index 9e6bf1874c..9c90938c68 100644 --- a/src/components/Clusters/components/ChooseStorage.js +++ b/src/components/Clusters/components/ChooseStorage.js @@ -1,5 +1,6 @@ import { FlexBox, RadioButton } from '@ui5/webcomponents-react'; import { useTranslation } from 'react-i18next'; +import { spacing } from '@ui5/webcomponents-react-base'; import './ChooseStorage.scss'; export function ChooseStorage({ storage, setStorage }) { @@ -7,7 +8,10 @@ export function ChooseStorage({ storage, setStorage }) { return ( <> - <p className="cluster-wizard__storage-preference"> + <p + className="cluster-wizard__storage-preference" + style={spacing.sapUiTinyMarginBottom} + > {`${t('clusters.storage.storage-preference')}:`} </p> <FlexBox direction="Column"> diff --git a/src/components/Clusters/components/ClusterPreview.js b/src/components/Clusters/components/ClusterPreview.js index 9e46293aec..6c60cad017 100644 --- a/src/components/Clusters/components/ClusterPreview.js +++ b/src/components/Clusters/components/ClusterPreview.js @@ -3,8 +3,12 @@ import { Button, FlexBox, RadioButton, Title } from '@ui5/webcomponents-react'; import { useTranslation } from 'react-i18next'; import './ClusterPreview.scss'; import { spacing } from '@ui5/webcomponents-react-base'; -import { findInitialValue } from '../views/EditCluster/EditCluster'; +import { + findInitialValue, + findInitialValues, +} from '../views/EditCluster/EditCluster'; import { getUserIndex } from '../shared'; +import { Tokens } from 'shared/components/Tokens'; export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { const { t } = useTranslation(); @@ -25,7 +29,7 @@ export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { 'oidc-client-secret', userIndex, ); - const extraScope = findInitialValue( + const extraScopes = findInitialValues( kubeconfig, 'oidc-extra-scope', userIndex, @@ -75,7 +79,7 @@ export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { <div>{clientSecret}</div> </> )} - {extraScope && ( + {extraScopes && ( <> <p className="cluster-preview__data-header" @@ -86,7 +90,7 @@ export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { > {t('clusters.labels.scopes')}: </p> - <div>{extraScope}</div> + {<Tokens tokens={extraScopes} />} </> )} </> @@ -112,123 +116,125 @@ export function ClusterPreview({ kubeconfig, storage, setSelected, hasAuth }) { }; return ( - <div className="cluster-preview add-cluster__content-container"> - <Title level="H5" style={spacing.sapUiMediumMarginBottom}> - {t('clusters.wizard.review')} - - {`1. ${t('configuration.title')}`} -

- {t('clusters.name_singular')}: -

-
-
{kubeconfig?.['current-context']}
- -
- {`2. ${t('clusters.wizard.authentication')}`} - -
-
- {authenticationType === 'token' ? : } + {t('clusters.name_singular')}: +

+
+
{kubeconfig?.['current-context']}
+
- -
- {`3. ${t('clusters.wizard.storage')}`} -

- {`${t('clusters.storage.storage-preference')}:`} -

-
- + {authenticationType === 'token' ? : } +
+ +
+ {`3. ${t('clusters.wizard.storage')}`} +

- - - - - + + + + + + +

); diff --git a/src/components/Clusters/components/ClusterPreview.scss b/src/components/Clusters/components/ClusterPreview.scss index 7711785972..f9adae7937 100644 --- a/src/components/Clusters/components/ClusterPreview.scss +++ b/src/components/Clusters/components/ClusterPreview.scss @@ -2,7 +2,8 @@ overflow-y: scroll; overflow-x: hidden; height: 100%; - max-height: calc(90vh - 12rem); + min-height: calc(90vh - 11.85rem); + margin-bottom: 1rem; &__subtitle { padding-bottom: 1rem; diff --git a/src/components/Clusters/components/KubeconfigUpload/KubeconfigFileUpload.js b/src/components/Clusters/components/KubeconfigUpload/KubeconfigFileUpload.js index 2b6a06d271..a844d462c5 100644 --- a/src/components/Clusters/components/KubeconfigUpload/KubeconfigFileUpload.js +++ b/src/components/Clusters/components/KubeconfigUpload/KubeconfigFileUpload.js @@ -24,14 +24,14 @@ export function KubeconfigFileUpload({ onKubeconfigTextAdded }) { return (
- + <Title level="H5" style={spacing.sapUiTinyMarginBottom}> {t('clusters.wizard.kubeconfig')} <> <Button id="descriptionOpener" icon="hint" design="Transparent" - style={spacing.sapUiTinyMargin} + style={spacing.sapUiTinyMarginBegin} onClick={() => { setShowTitleDescription(true); }} diff --git a/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.js b/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.js index 2a38e58c3f..6350ea2005 100644 --- a/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.js +++ b/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.js @@ -35,7 +35,10 @@ export function KubeconfigUpload({ kubeconfig, setKubeconfig, formRef }) { return ( <div className="kubeconfig-upload"> - <div className="add-cluster__content-container"> + <div + className="add-cluster__content-container" + style={spacing.sapUiSmallMarginBottom} + > <KubeconfigFileUpload onKubeconfigTextAdded={text => { updateKubeconfig(text); @@ -58,11 +61,7 @@ export function KubeconfigUpload({ kubeconfig, setKubeconfig, formRef }) { yamlHideDisabled /> {error && ( - <MessageStrip - design="Negative" - hideCloseButton - style={spacing.sapUiSmallMarginTop} - > + <MessageStrip design="Negative" hideCloseButton> {t('common.create-form.editor-error', { error })} </MessageStrip> )} diff --git a/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.scss b/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.scss index ff992e00cc..703b304d31 100644 --- a/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.scss +++ b/src/components/Clusters/components/KubeconfigUpload/KubeconfigUpload.scss @@ -1,15 +1,23 @@ .kubeconfig-upload { + border-radius: 12px; display: flex; - flex-flow: column; - flex-grow: 1; - height: calc(90vh - 10rem); + flex-direction: column; + gap: 1rem; + min-height: calc(90vh - 11.85rem); + margin-bottom: 1rem; .editor-label { text-align: center; } .resource-form { - padding: 1rem 0 0 0; + padding: 0; + + .resource-form--panel, + .resource-form--panel::part(content) { + margin: 0 !important; + min-height: unset !important; + } &__wrapper { max-height: calc(90vh - 16rem - 290px); @@ -23,7 +31,6 @@ } &__form { - height: 100% !important; padding-bottom: 0 !important; } } diff --git a/src/components/Clusters/components/oidc-params.ts b/src/components/Clusters/components/oidc-params.ts index 6cf79aa571..fd013dfdf3 100644 --- a/src/components/Clusters/components/oidc-params.ts +++ b/src/components/Clusters/components/oidc-params.ts @@ -4,7 +4,8 @@ const OIDC_PARAM_NAMES = new Map([ ['--oidc-issuer-url', 'issuerUrl'], ['--oidc-client-id', 'clientId'], ['--oidc-client-secret', 'clientSecret'], - ['--oidc-extra-scope', 'scope'], + ['--oidc-extra-scope', 'scopes'], + ['--oidc-use-access-token', 'useAccessToken'], ]); export function parseOIDCparams({ exec: commandData }: KubeconfigOIDCAuth) { @@ -27,7 +28,7 @@ export function parseOIDCparams({ exec: commandData }: KubeconfigOIDCAuth) { * For an interactive example that demonstrates this regex pattern with various inputs and explains each part of the * expression in detail, visit the provided link: https://regex101.com/r/3hc8qX/2 */ - const regex = new RegExp('^(?<key>[^=]+)(?:=(?<value>.*$))'); + const regex = new RegExp('^(?<key>[^=]+)(?:=(?<value>.*$))?'); const match = arg.match(regex); if (!match) { @@ -35,13 +36,24 @@ export function parseOIDCparams({ exec: commandData }: KubeconfigOIDCAuth) { } const argKey: string = match.groups?.key || ''; - const argValue: string = match.groups?.value || ''; + const argValue: string | boolean = match.groups?.value ?? true; if (!OIDC_PARAM_NAMES.has(argKey)) return; const outputKey = OIDC_PARAM_NAMES.get(argKey)!; - if (output[outputKey]) output[outputKey] += ' ' + argValue; - else output[outputKey] = argValue; + if (output[outputKey]) { + if (outputKey === 'scopes') { + output[outputKey].push(argValue); + } else { + output[outputKey] += ' ' + argValue; + } + } else { + if (outputKey === 'scopes') { + output[outputKey] = [argValue]; + } else { + output[outputKey] = argValue; + } + } }); return output; @@ -60,7 +72,7 @@ export function createLoginCommand( issuerUrl: string; clientId: string; clientSecret?: string; - scope: string; + scopes: string[]; }, execRest: object, ): LoginCommand { @@ -74,9 +86,9 @@ export function createLoginCommand( `--oidc-issuer-url=${oidcConfig.issuerUrl || ''}`, `--oidc-client-id=${oidcConfig.clientId || ''}`, `--oidc-client-secret=${oidcConfig.clientSecret || ''}`, - oidcConfig.scope - ? `--oidc-extra-scope=${oidcConfig.scope || ''}` - : `--oidc-extra-scope=openid ${oidcConfig.scope || ''}`, + ...(oidcConfig.scopes?.length + ? oidcConfig.scopes.map(scope => `--oidc-extra-scope=${scope || ''}`) + : [`--oidc-extra-scope=openid`]), '--grant-type=auto', ], }; diff --git a/src/components/Clusters/components/test/oidc-params.test.js b/src/components/Clusters/components/test/oidc-params.test.js index 8ad7eb46e9..b74d551fde 100644 --- a/src/components/Clusters/components/test/oidc-params.test.js +++ b/src/components/Clusters/components/test/oidc-params.test.js @@ -14,6 +14,7 @@ describe('parseOIDCparams', () => { '--oidc-client-id=hasselhoff', '--oidc-client-secret=hasselhoffsecret', '--oidc-extra-scope=peach', + '--oidc-use-access-token', ], }, }; @@ -21,7 +22,8 @@ describe('parseOIDCparams', () => { clientId: 'hasselhoff', clientSecret: 'hasselhoffsecret', issuerUrl: 'https://coastguard.gov.us', - scope: 'peach', + scopes: ['peach'], + useAccessToken: true, }); }); @@ -40,11 +42,11 @@ describe('parseOIDCparams', () => { clientId: 'hasselhoff', clientSecret: 'hasselhoff=secret', issuerUrl: 'https://coastguard.gov.us', - scope: 'peach', + scopes: ['peach'], }); }); - it('Concatinates params', () => { + it('Multiple scopes', () => { const input = { exec: { args: [ @@ -55,7 +57,7 @@ describe('parseOIDCparams', () => { }, }; expect(parseOIDCparams(input)).toMatchObject({ - scope: 'peach melon plum', + scopes: ['peach', 'melon', 'plum'], }); }); diff --git a/src/components/Clusters/shared.ts b/src/components/Clusters/shared.ts index bd6b5777e5..49a772fda6 100644 --- a/src/components/Clusters/shared.ts +++ b/src/components/Clusters/shared.ts @@ -14,6 +14,7 @@ import { createUserManager } from 'state/authDataAtom'; import { useNavigate } from 'react-router-dom'; import { useSetRecoilState } from 'recoil'; import { removePreviousPath } from 'state/useAfterInitHook'; +import { parseOIDCparams } from 'components/Clusters/components/oidc-params'; function addCurrentCluster( params: NonNullable<ActiveClusterState>, @@ -56,7 +57,7 @@ export function deleteCluster( const prevCredentials = prev?.[clusterName]?.currentContext.user.user; if (!hasNonOidcAuth(prevCredentials)) { const userManager = createUserManager( - prevCredentials as KubeconfigOIDCAuth, + parseOIDCparams(prevCredentials as KubeconfigOIDCAuth), ); userManager.removeUser().catch(console.warn); } @@ -128,7 +129,7 @@ export function hasKubeconfigAuth(kubeconfig: Kubeconfig) { } const oidcData = tryParseOIDCparams(user as KubeconfigOIDCAuth); - if (oidcData?.issuerUrl && oidcData?.clientId && oidcData?.scope) { + if (oidcData?.issuerUrl && oidcData?.clientId && oidcData?.scopes) { return oidcData; } } catch (e) { diff --git a/src/components/Clusters/views/EditCluster/AuthenticationDropdown.js b/src/components/Clusters/views/EditCluster/AuthenticationDropdown.js index 70f150d4fe..1f0fb9ae6c 100644 --- a/src/components/Clusters/views/EditCluster/AuthenticationDropdown.js +++ b/src/components/Clusters/views/EditCluster/AuthenticationDropdown.js @@ -14,7 +14,6 @@ export function AuthenticationTypeDropdown({ type, setType }) { options={options} selectedKey={type} onSelect={(_, selected) => setType(selected.key)} - fullWidth required /> ); diff --git a/src/components/Clusters/views/EditCluster/EditCluster.js b/src/components/Clusters/views/EditCluster/EditCluster.js index 5417ebc78a..cd362609e6 100644 --- a/src/components/Clusters/views/EditCluster/EditCluster.js +++ b/src/components/Clusters/views/EditCluster/EditCluster.js @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next'; import { useSetRecoilState } from 'recoil'; import { ResourceForm } from 'shared/ResourceForm'; -import { K8sNameField } from 'shared/ResourceForm/fields'; +import { K8sNameField, TextArrayInput } from 'shared/ResourceForm/fields'; import { ChooseStorage } from 'components/Clusters/components/ChooseStorage'; import { ErrorBoundary } from 'shared/components/ErrorBoundary/ErrorBoundary'; import { useNotification } from 'shared/contexts/NotificationContext'; @@ -19,11 +19,29 @@ import { addCluster, getContext, deleteCluster } from '../../shared'; import { spacing } from '@ui5/webcomponents-react-base'; import { getUserIndex } from '../../shared'; +export const findInitialValues = (kubeconfig, id, userIndex = 0) => { + const elementsWithId = + kubeconfig?.users?.[userIndex]?.user?.exec?.args.filter(el => + el?.includes(id), + ) || []; + const regex = new RegExp(`${id}=(?<value>.*)`); + const values = []; + + for (const element of elementsWithId) { + const match = regex.exec(element); + if (match?.groups?.value) { + values.push(match.groups.value); + } + } + + return values; +}; + export const findInitialValue = (kubeconfig, id, userIndex = 0) => { if (kubeconfig?.users?.[userIndex]?.user?.exec?.args) { const elementWithId = kubeconfig?.users?.[ userIndex - ]?.user?.exec?.args.find(el => el.includes(id)); + ]?.user?.exec?.args.find(el => el?.includes(id)); const regex = new RegExp(`${id}=(?<value>.*)`); return regex.exec(elementWithId)?.groups?.value || ''; } @@ -56,7 +74,7 @@ export const ClusterDataForm = ({ 'oidc-client-secret', userIndex, ); - const scopes = findInitialValue(kubeconfig, 'oidc-extra-scope', userIndex); + const scopes = findInitialValues(kubeconfig, 'oidc-extra-scope', userIndex); useEffect(() => { setAuthenticationType( @@ -89,9 +107,9 @@ export const ClusterDataForm = ({ `--oidc-issuer-url=${config.issuerUrl}`, `--oidc-client-id=${config.clientId}`, `--oidc-client-secret=${config.clientSecret}`, - findInitialValue(kubeconfig, 'oidc-extra-scope', userIndex) - ? `--oidc-extra-scope=${config.scopes}` - : `--oidc-extra-scope=openid ${config.scopes}`, + ...(config.scopes?.length + ? config.scopes.map(scope => `--oidc-extra-scope=${scope || ''}`) + : [`--oidc-extra-scope=openid`]), '--grant-type=auto', ], }; @@ -127,10 +145,10 @@ export const ClusterDataForm = ({ createOIDC('clientSecret', val); }} /> - <ResourceForm.FormField - label={t('clusters.labels.scopes')} - input={Inputs.Text} + <TextArrayInput required + defaultOpen + title={t('clusters.labels.scopes')} value={scopes} setValue={val => { createOIDC('scopes', val); diff --git a/src/components/Extensibility/components-form/NumberRenderer.js b/src/components/Extensibility/components-form/NumberRenderer.js index db4f4a85c9..7e8528c817 100644 --- a/src/components/Extensibility/components-form/NumberRenderer.js +++ b/src/components/Extensibility/components-form/NumberRenderer.js @@ -16,6 +16,7 @@ export function NumberRenderer({ required, compact, placeholder, + editMode, ...props }) { const { tFromStoreKeys, t: tExt } = useGetTranslation(); @@ -26,6 +27,8 @@ export function NumberRenderer({ ['min', 'max'].map(prop => [prop, schema.get(prop)]), ); + const disableOnEdit = schema.get('disableOnEdit'); + return ( <ResourceForm.FormField value={value} @@ -44,6 +47,7 @@ export function NumberRenderer({ data-testid={storeKeys.join('.') || tFromStoreKeys(storeKeys, schema)} input={Inputs.Number} compact={compact} + disabled={disableOnEdit && editMode} {...numberProps} {...getPropsFromSchema(schema, required, tExt)} /> diff --git a/src/components/Extensibility/components-form/index.js b/src/components/Extensibility/components-form/index.js index 5b25fb0612..4552d7e79b 100644 --- a/src/components/Extensibility/components-form/index.js +++ b/src/components/Extensibility/components-form/index.js @@ -77,6 +77,7 @@ export const widgets = { */ Text: StringRenderer, Switch: SwitchRenderer, + Number: NumberRenderer, Jsonata, /* Text: TextRenderer, diff --git a/src/components/Gardener/GardenerLogin.tsx b/src/components/Gardener/GardenerLogin.tsx index f517dd8df1..8ee46855ec 100644 --- a/src/components/Gardener/GardenerLogin.tsx +++ b/src/components/Gardener/GardenerLogin.tsx @@ -7,6 +7,7 @@ import { createUserManager } from 'state/authDataAtom'; import { KubeconfigOIDCAuth } from 'types'; import { GardenerLoginFeature } from './GardenerLoginFeature'; import { useGardenerLogin } from './useGardenerLoginFunction'; +import { parseOIDCparams } from 'components/Clusters/components/oidc-params'; import { spacing } from '@ui5/webcomponents-react-base'; @@ -29,7 +30,10 @@ export default function GardenerLogin() { setToken(user.token); } else { const auth = user as KubeconfigOIDCAuth; - const userManager = createUserManager(auth, '/gardener-login'); + const userManager = createUserManager( + parseOIDCparams(auth), + '/gardener-login', + ); try { const storedUser = await userManager.getUser(); const user = diff --git a/src/resources/RoleBindings/RoleForm.js b/src/resources/RoleBindings/RoleForm.js index 2325c27076..0aa5c0527a 100644 --- a/src/resources/RoleBindings/RoleForm.js +++ b/src/resources/RoleBindings/RoleForm.js @@ -61,31 +61,28 @@ export const RoleForm = ({ label={t('role-bindings.create-modal.role')} propertyPath="$.roleRef.name" input={props => ( - <div className="bsl-col-md--11"> - <ComboBox - id="role" - aria-label="Role Combobox" - disabled={props.disabled || !options?.length} - filter="Contains" - inputRef={props.inputRef} - placeholder={t('common.messages.type-to-select', { - value: t( - binding.roleRef?.kind === 'ClusterRole' - ? 'cluster-roles.name_singular' - : 'roles.name_singular', - ), - })} - value={ - options.find(o => o.key === props.value)?.text ?? props.value - } - onChange={event => onChange(event, props)} - onInput={event => onChange(event, props)} - > - {options.map(option => ( - <ComboBoxItem id={option.key} text={option.text} /> - ))} - </ComboBox> - </div> + <ComboBox + className="bsl-col-md--12" + id="role" + aria-label="Role Combobox" + disabled={props.disabled || !options?.length} + filter="Contains" + inputRef={props.inputRef} + placeholder={t('common.messages.type-to-select', { + value: t( + binding.roleRef?.kind === 'ClusterRole' + ? 'cluster-roles.name_singular' + : 'roles.name_singular', + ), + })} + value={options.find(o => o.key === props.value)?.text ?? props.value} + onChange={event => onChange(event, props)} + onInput={event => onChange(event, props)} + > + {options.map(option => ( + <ComboBoxItem id={option.key} text={option.text} /> + ))} + </ComboBox> )} /> ); diff --git a/src/resources/RoleBindings/SubjectForm.js b/src/resources/RoleBindings/SubjectForm.js index 87380e3158..463f252b9c 100644 --- a/src/resources/RoleBindings/SubjectForm.js +++ b/src/resources/RoleBindings/SubjectForm.js @@ -65,7 +65,7 @@ export function SingleSubjectForm({ tooltipContent={t('role-bindings.create-modal.tooltips.kind')} label={t('role-bindings.create-modal.kind')} input={() => ( - <Select onChange={onChange} className="bsl-col-md--11"> + <Select onChange={onChange} className="bsl-col-md--12"> {SUBJECT_KINDS.map(kind => ( <Option value={kind} selected={(subject.kind || '') === kind}> {kind} diff --git a/src/resources/ServiceAccounts/ServiceAccountDetails.js b/src/resources/ServiceAccounts/ServiceAccountDetails.js index b03ba75fff..ca8d7c41ab 100644 --- a/src/resources/ServiceAccounts/ServiceAccountDetails.js +++ b/src/resources/ServiceAccounts/ServiceAccountDetails.js @@ -7,6 +7,10 @@ import ServiceAccountCreate from './ServiceAccountCreate'; import { Button } from '@ui5/webcomponents-react'; import { TokenRequestModal } from './TokenRequestModal/TokenRequestModal'; import { ResourceDescription } from 'resources/ServiceAccounts'; +import { EventsList } from 'shared/components/EventsList'; +import { filterByResource } from 'hooks/useMessageList'; +import { UI5Panel } from 'shared/components/UI5Panel/UI5Panel'; +import { LayoutPanelRow } from 'shared/components/LayoutPanelRow/LayoutPanelRow'; const ServiceAccountSecrets = serviceAccount => { const namespace = serviceAccount.metadata.namespace; @@ -59,17 +63,32 @@ const ServiceAccountImagePullSecrets = serviceAccount => { export default function ServiceAccountDetails(props) { const { t } = useTranslation(); const [isTokenModalOpen, setTokenModalOpen] = useState(false); - const customColumns = [ - { - header: t('service-accounts.headers.auto-mount-token'), - value: value => ( - <ServiceAccountTokenStatus - automount={value.automountServiceAccountToken} - /> - ), - }, - ]; + const Events = () => ( + <EventsList + key="events" + namespace={props.namespace} + filter={filterByResource('ServiceAccount', props.resourceName)} + hideInvolvedObjects={true} + /> + ); + + const Configuration = value => ( + <UI5Panel + fixed + keyComponent={'serviceaccount-configuration'} + title={t('configuration.title')} + > + <LayoutPanelRow + name={t('service-accounts.headers.auto-mount-token')} + value={ + <ServiceAccountTokenStatus + automount={value.automountServiceAccountToken} + /> + } + /> + </UI5Panel> + ); const headerActions = [ <Button key="generate-token-request" @@ -83,10 +102,11 @@ export default function ServiceAccountDetails(props) { <> <ResourceDetails customComponents={[ + Configuration, ServiceAccountSecrets, ServiceAccountImagePullSecrets, + Events, ]} - customColumns={customColumns} createResourceForm={ServiceAccountCreate} description={ResourceDescription} headerActions={headerActions} diff --git a/src/shared/ResourceForm/components/FormField.js b/src/shared/ResourceForm/components/FormField.js index b8ae44fbe4..97c49f4777 100644 --- a/src/shared/ResourceForm/components/FormField.js +++ b/src/shared/ResourceForm/components/FormField.js @@ -49,25 +49,23 @@ export function FormField({ )} </div> </FlexBox> - <FlexBox wrap="Wrap" alignItems="Center"> - <div className="bsl-col-md--12"> - <FlexBox wrap="Wrap" alignItems="Center"> - {messageStrip - ? messageStrip - : input({ - updatesOnInput, - required, - disabled, - className: 'full-width', - ...inputProps, - })} - {inputInfo && ( - <Label wrappingType="Normal" style={{ marginTop: '5px' }}> - {inputInfoLink} - </Label> - )} - </FlexBox> - </div> + <FlexBox wrap="Wrap" alignItems="Center" className="full-width"> + <FlexBox wrap="Wrap" alignItems="Center" className="bsl-col-md--12"> + {messageStrip + ? messageStrip + : input({ + updatesOnInput, + required, + disabled, + className: 'full-width', + ...inputProps, + })} + {inputInfo && ( + <Label wrappingType="Normal" style={{ marginTop: '5px' }}> + {inputInfoLink} + </Label> + )} + </FlexBox> </FlexBox> </FlexBox> ); diff --git a/src/shared/components/Dropdown/Dropdown.js b/src/shared/components/Dropdown/Dropdown.js index 7e3eca275c..e6de3522f4 100644 --- a/src/shared/components/Dropdown/Dropdown.js +++ b/src/shared/components/Dropdown/Dropdown.js @@ -60,7 +60,11 @@ export function Dropdown({ ); return ( - <FlexBox className="flexbox-gap" justifyContent="Center" direction="Column"> + <FlexBox + className="flexbox-gap full-width" + justifyContent="Center" + direction="Column" + > {label && <Label forElement={id}>{label}</Label>} {inlineHelp ? ( <Tooltip content={inlineHelp}>{combobox}</Tooltip> diff --git a/src/shared/components/ResourceGraph/DetailsCard/DetailsCard.tsx b/src/shared/components/ResourceGraph/DetailsCard/DetailsCard.tsx index f1f66b339d..cf074a5cbb 100644 --- a/src/shared/components/ResourceGraph/DetailsCard/DetailsCard.tsx +++ b/src/shared/components/ResourceGraph/DetailsCard/DetailsCard.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useUrl } from 'hooks/useUrl'; -import { useRecoilValue } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { Button } from '@ui5/webcomponents-react'; import { Labels } from 'shared/components/Labels/Labels'; @@ -12,6 +12,7 @@ import pluralize from 'pluralize'; import { spacing } from '@ui5/webcomponents-react-base'; import './DetailsCard.scss'; +import { columnLayoutState } from 'state/columnLayoutAtom'; export function DetailsCard({ resource, @@ -24,6 +25,7 @@ export function DetailsCard({ const navigate = useNavigate(); const { clusterUrl } = useUrl(); const nodes = useRecoilValue(allNodesSelector); + const [, setLayoutColumn] = useRecoilState(columnLayoutState); return ( <div className="details-card-wrapper"> @@ -64,6 +66,12 @@ export function DetailsCard({ `${namespacePart}${node.resourceType}/${resource.metadata.name}`, ), ); + + setLayoutColumn({ + layout: 'OneColumn', + midColumn: null, + endColumn: null, + }); }} > {t('resource-graph.buttons.go-to-details')} diff --git a/src/shared/components/ServiceAccountTokenStatus.js b/src/shared/components/ServiceAccountTokenStatus.js deleted file mode 100644 index 7bf68bdc97..0000000000 --- a/src/shared/components/ServiceAccountTokenStatus.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { StatusBadge } from 'shared/components/StatusBadge/StatusBadge'; -import { useTranslation } from 'react-i18next'; - -export const ServiceAccountTokenStatus = ({ automount }) => { - const { t } = useTranslation(); - - const accountTokenValues = automount - ? { - type: 'Warning', - tooltipContent: t( - 'service-accounts.auto-mount-token.descriptions.enabled', - ), - status: t('service-accounts.auto-mount-token.enabled'), - } - : { - type: 'Information', - tooltipContent: t( - 'service-accounts.auto-mount-token.descriptions.disabled', - ), - status: t('service-accounts.auto-mount-token.disabled'), - }; - - return ( - <StatusBadge - type={accountTokenValues.type} - tooltipContent={accountTokenValues.tooltipContent} - > - {accountTokenValues.status} - </StatusBadge> - ); -}; diff --git a/src/shared/components/ServiceAccountTokenStatus.tsx b/src/shared/components/ServiceAccountTokenStatus.tsx new file mode 100644 index 0000000000..558156c714 --- /dev/null +++ b/src/shared/components/ServiceAccountTokenStatus.tsx @@ -0,0 +1,77 @@ +import { StatusBadge } from 'shared/components/StatusBadge/StatusBadge'; +import { useTranslation } from 'react-i18next'; +import { Popover, Token } from '@ui5/webcomponents-react'; +import { createPortal } from 'react-dom'; +import { useRef, useState } from 'react'; + +export const ServiceAccountTokenStatus = ({ + automount, +}: { + automount: boolean; +}) => { + const { t } = useTranslation(); + const [openPopover, setOpenPopover] = useState(false); + const popoverRef = useRef<any>(null); + const openerRef = useRef(null); + + const handleOpenerClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (popoverRef.current) { + popoverRef.current.opener = openerRef.current; + setOpenPopover(prev => !prev); + } + }; + + const accountTokenValues = automount + ? { + type: 'Warning', + tooltipContent: t( + 'service-accounts.auto-mount-token.descriptions.enabled', + ), + status: t('service-accounts.auto-mount-token.enabled'), + } + : { + type: 'Neutral', + tooltipContent: t( + 'service-accounts.auto-mount-token.descriptions.disabled', + ), + status: t('service-accounts.auto-mount-token.disabled'), + }; + + const tokenElement = ( + <button ref={openerRef} onClick={handleOpenerClick} className="badge-wrap"> + <Token + style={{ textTransform: 'capitalize' }} + text={accountTokenValues.status} + readonly + ></Token> + </button> + ); + + return automount ? ( + <StatusBadge + type={accountTokenValues.type} + tooltipContent={accountTokenValues.tooltipContent} + > + {accountTokenValues.status} + </StatusBadge> + ) : ( + <> + {createPortal( + <Popover + ref={popoverRef} + open={openPopover} + onAfterClose={e => { + e.stopPropagation(); + setOpenPopover(false); + }} + placementType="Right" + > + {accountTokenValues.tooltipContent} + </Popover>, + document.body, + )} + {tokenElement} + </> + ); +}; diff --git a/src/state/authDataAtom.ts b/src/state/authDataAtom.ts index 395f71f06f..c785692151 100644 --- a/src/state/authDataAtom.ts +++ b/src/state/authDataAtom.ts @@ -37,21 +37,25 @@ type handleLoginProps = { }; export function createUserManager( - userCredentials: KubeconfigOIDCAuth, + oidcParams: { + issuerUrl: string; + clientId: string; + clientSecret: string; + scopes: string[]; + }, redirectPath = '', ) { - const { issuerUrl, clientId, clientSecret, scope } = parseOIDCparams( - userCredentials, - ); + const uniqueScopes = new Set(oidcParams.scopes || []); + uniqueScopes.delete('openid'); return new UserManager({ redirect_uri: window.location.origin + redirectPath, post_logout_redirect_uri: window.location.origin + '/logout.html', loadUserInfo: true, - client_id: clientId, - authority: issuerUrl, - client_secret: clientSecret, - scope: scope || 'openid', + client_id: oidcParams.clientId, + authority: oidcParams.issuerUrl, + client_secret: oidcParams.clientSecret, + scope: `openid ${[...uniqueScopes].join(' ')}`, response_type: 'code', response_mode: 'query', }); @@ -63,11 +67,15 @@ async function handleLogin({ onAfterLogin, onError, }: handleLoginProps): Promise<void> { - const setupAuthEventsHooks = (userManager: UserManager) => { + const setupAuthEventsHooks = ( + userManager: UserManager, + useAccessToken: boolean, + ) => { userManager.events.addAccessTokenExpiring(async () => { const user = await userManager.signinSilent(); - const token = user?.id_token!; - setAuth({ token }); + setAuth({ + token: useAccessToken ? user?.access_token! : user?.id_token!, + }); }); userManager.events.addSilentRenewError(e => { console.warn('silent renew failed', e); @@ -79,28 +87,34 @@ async function handleLogin({ const setupVisibilityEventsHooks = ( userManager: UserManager, user: User | null, + useAccessToken: boolean, ) => { document.addEventListener('visibilitychange', async event => { if (document.visibilityState === 'visible') { if (!!user?.expired || (user?.expires_in && user?.expires_in <= 2)) { user = await userManager.signinSilent(); - const token = user?.id_token!; - setAuth({ token }); + setAuth({ + token: useAccessToken ? user?.access_token! : user?.id_token!, + }); } } }); }; - const userManager = createUserManager(userCredentials); + const oidcParams = parseOIDCparams(userCredentials); + const userManager = createUserManager(oidcParams); + + const useAccessToken: boolean = oidcParams?.useAccessToken ?? false; + try { const storedUser = await userManager.getUser(); const user = storedUser && !storedUser.expired ? storedUser : await userManager.signinRedirectCallback(window.location.href); - setAuth({ token: user?.id_token! }); - setupAuthEventsHooks(userManager); - setupVisibilityEventsHooks(userManager, user); + setAuth({ token: useAccessToken ? user?.access_token : user?.id_token! }); + setupAuthEventsHooks(userManager, useAccessToken); + setupVisibilityEventsHooks(userManager, user, useAccessToken); onAfterLogin(); } catch (e) { // ignore 'No state in response' error - it means we didn't fire login request yet