Compare commits

...

22 Commits

Author SHA1 Message Date
testa113
02611563f3 fix(auth): use pure admin [EE-6723]
Some checks are pending
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
Test / test-client (push) Waiting to run
2024-02-15 16:52:25 +13:00
Chaim Lev-Ari
edea9e3481 feat(auth): add useIsEdgeAdmin hook [EE-6627] (#11101) 2024-02-14 19:50:26 -03:00
Ali
c08b5af85a fix(insight): split insight from input [EE-6693] (#11177)
Co-authored-by: testa113 <testa113>
2024-02-15 10:46:02 +13:00
Prabhat Khera
ed861044a7 Revert "fix(logs): add NOCOLOR option for use when exporting to greylog etc […" (#11178)
This reverts commit aca6d33548.
2024-02-15 06:26:22 +13:00
Chaim Lev-Ari
a83321ebe6 feat(ui): write tests [EE-6685] (#11082) 2024-02-14 17:25:32 +02:00
Ali
513cd9c9b3 fix(configs): correct 'external' display in tables [EE-6649] (#11111)
Co-authored-by: testa113 <testa113>
2024-02-14 11:48:05 +13:00
Ali
dc94bf141e fix(stacks): add app form stacks input [EE-6693] (#11105) 2024-02-14 09:01:02 +13:00
Dakota Walsh
24471a9ae1 fix(restore): add S3 teaser [EE-6675] (#11096) 2024-02-14 08:40:34 +13:00
Matt Hook
aca6d33548 fix(logs): add NOCOLOR option for use when exporting to greylog etc [EE-6696] (#11107) 2024-02-14 07:54:47 +13:00
Ali
ca77b85c65 fix(kube-owner): owner labels from resources created via manifest [EE-6647] (#11103)
Co-authored-by: testa113 <testa113>
2024-02-12 15:30:59 +13:00
Prabhat Khera
1fd4291630 fix(ui): stackname auto fill on create from manifest screen [EE-6688] (#11100)
* fix(ui): stackname auto fill on create from manifest screen [EE-6688]

* address review comment
2024-02-12 10:54:24 +13:00
Ali
08dd7f6d2a fix(auth): isAdmin redirect for wizard [EE-6669] (#11075)
Co-authored-by: testa113 <testa113>
2024-02-12 08:04:44 +13:00
Prabhat Khera
ce4b0e759c fix(ui): scroll issue [EE-6667 (#11085)
* Fix scroll issue

* fix minorissue

* address review comments

* add comment
2024-02-09 15:35:38 +13:00
Steven Kang
538e7a823b fix: pre-release build only after merging (#11098) 2024-02-09 15:26:39 +13:00
Matt Hook
956e8d3c59 fix(docs): fix swagger docs for webhook params [EE-6668] (#11089) 2024-02-09 14:44:29 +13:00
Prabhat Khera
1c5458f0d4 fix(kube): ingress path duplication issue [EE-6649] (#11087) 2024-02-09 07:49:57 +13:00
Prabhat Khera
f6085ffad7 fix stack name update issue (#11065) 2024-02-08 13:51:06 +13:00
Matt Hook
490bda2eaf fix(kube-apps): add helm insights, remove namespace insights panel [EE-6671] (#11078) 2024-02-08 11:18:48 +13:00
Prabhat Khera
d601d8eb7b fix(UI): some minor fixes [EE-6667] (#11062)
* minor tweeks for kubernetes settings

* address review comments
2024-02-06 12:17:35 +13:00
Steven Kang
b0564b9238 Pre-release as part of the CI (#11067)
* feat: add pre-release
* feat: add extension
* feat: fix typo
2024-02-05 18:29:12 +13:00
Prabhat Khera
8922585a70 keep labels on edit ingress, configmaps and secrets (#11063) 2024-02-05 16:30:31 +13:00
Ali
d7cf2284dc fix(r2a): don't set errors to undefined [EE-6665] (#11060)
Co-authored-by: testa113 <testa113>
2024-02-05 14:24:15 +13:00
111 changed files with 1133 additions and 422 deletions

View File

@@ -5,7 +5,7 @@ on:
push:
branches:
- 'develop'
- '!release/*'
- 'release/*'
pull_request:
branches:
- 'develop'
@@ -20,8 +20,8 @@ on:
- ready_for_review
env:
DOCKER_HUB_REPO: portainerci/portainer
NODE_ENV: testing
DOCKER_HUB_REPO: portainerci/portainer-ce
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
GO_VERSION: 1.21.6
NODE_VERSION: 18.x
@@ -30,81 +30,59 @@ jobs:
strategy:
matrix:
config:
- { platform: linux, arch: amd64 }
- { platform: linux, arch: arm64 }
- { platform: linux, arch: amd64, version: "" }
- { platform: linux, arch: arm64, version: "" }
- { platform: linux, arch: arm, version: "" }
- { platform: linux, arch: ppc64le, version: "" }
- { platform: linux, arch: s390x, version: "" }
- { platform: windows, arch: amd64, version: 1809 }
- { platform: windows, arch: amd64, version: ltsc2022 }
runs-on: arc-runner-set
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
steps:
- name: '[preparation] checkout the current branch'
uses: actions/checkout@v3.5.3
uses: actions/checkout@v4.1.1
with:
ref: ${{ github.event.inputs.branch }}
- name: '[preparation] set up golang'
uses: actions/setup-go@v4.0.1
uses: actions/setup-go@v5.0.0
with:
go-version: ${{ env.GO_VERSION }}
cache: false
- name: '[preparation] cache paths'
id: cache-dir-path
run: |
echo "yarn-cache-dir=$(yarn cache dir)" >> "$GITHUB_OUTPUT"
echo "go-build-dir=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
echo "go-mod-dir=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
- name: '[preparation] cache go'
uses: actions/cache@v3
with:
path: |
${{ steps.cache-dir-path.outputs.go-build-dir }}
${{ steps.cache-dir-path.outputs.go-mod-dir }}
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-
enableCrossOsArchive: true
- name: '[preparation] set up node.js'
uses: actions/setup-node@v3
uses: actions/setup-node@v4.0.1
with:
node-version: ${{ env.NODE_VERSION }}
cache: ''
- name: '[preparation] cache yarn'
uses: actions/cache@v3
with:
path: |
**/node_modules
${{ steps.cache-dir-path.outputs.yarn-cache-dir }}
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-
enableCrossOsArchive: true
cache: 'yarn'
- name: '[preparation] set up qemu'
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3.0.0
- name: '[preparation] set up docker context for buildx'
run: docker context create builders
- name: '[preparation] set up docker buildx'
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3.0.0
with:
endpoint: builders
- name: '[preparation] docker login'
uses: docker/login-action@v2.2.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: '[preparation] set the container image tag'
run: |
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
# use the release branch name as the tag for release branches
# for instance, release/2.19 becomes 2.19
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
# use pr${{ github.event.number }} as the tag for pull requests
# for instance, pr123
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
else
# replace / with - in the branch name
# for instance, feature/1.0.0 -> feature-1.0.0
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
fi
if [ "${{ matrix.config.platform }}" == "windows" ]; then
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}"
else
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}-${{ matrix.config.arch }}"
fi
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}" >> $GITHUB_ENV
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}" >> $GITHUB_ENV
- name: '[execution] build linux & windows portainer binaries'
run: |
export YARN_VERSION=$(yarn --version)
@@ -112,6 +90,12 @@ jobs:
export BUILDNUMBER=${GITHUB_RUN_NUMBER}
GIT_COMMIT_HASH_LONG=${{ github.sha }}
export GIT_COMMIT_HASH_SHORT={GIT_COMMIT_HASH_LONG:0:7}
NODE_ENV="testing"
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
NODE_ENV="production"
fi
make build-all PLATFORM=${{ matrix.config.platform }} ARCH=${{ matrix.config.arch }} ENV=${NODE_ENV}
env:
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
@@ -123,35 +107,70 @@ jobs:
else
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
fi
fi
env:
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
build_manifests:
runs-on: arc-runner-set
runs-on: ubuntu-latest
if: github.event.pull_request.draft == false
needs: [build_images]
steps:
- name: '[preparation] docker login'
uses: docker/login-action@v2.2.0
uses: docker/login-action@v3.0.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: '[preparation] set up docker context for buildx'
run: docker version && docker context create builders
- name: '[preparation] set up docker buildx'
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3.0.0
with:
endpoint: builders
- name: '[execution] build and push manifests'
run: |
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
# use the release branch name as the tag for release branches
# for instance, release/2.19 becomes 2.19
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
# use pr${{ github.event.number }} as the tag for pull requests
# for instance, pr123
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
else
# replace / with - in the branch name
# for instance, feature/1.0.0 -> feature-1.0.0
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
fi
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64"
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x"
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
fi

View File

@@ -22,7 +22,7 @@ type webhookListOperationFilters struct {
// @tags webhooks
// @accept json
// @produce json
// @param filters query webhookListOperationFilters false "Filters"
// @param filters query string false "Filters (json-string)" example({"EndpointID":1,"ResourceID":"abc12345-abcd-2345-ab12-58005b4a0260"})
// @success 200 {array} portainer.Webhook
// @failure 400
// @failure 500

View File

@@ -241,7 +241,10 @@ func (kcl *KubeClient) DeleteIngresses(reqs models.K8sIngressDeleteRequests) err
// UpdateIngress updates an existing ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInfo) error {
ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace)
var ingress netv1.Ingress
ingress, err := ingressClient.Get(context.Background(), info.Name, metav1.GetOptions{})
if err != nil {
return err
}
ingress.Name = info.Name
ingress.Namespace = info.Namespace
@@ -278,6 +281,7 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
})
}
ingress.Spec.Rules = make([]netv1.IngressRule, 0)
for rule, paths := range rules {
ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{
Host: rule,
@@ -299,6 +303,6 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
}
}
_, err := ingressClient.Update(context.Background(), &ingress, metav1.UpdateOptions{})
_, err = ingressClient.Update(context.Background(), ingress, metav1.UpdateOptions{})
return err
}

View File

@@ -17,6 +17,7 @@
html {
font-size: 16px;
overflow-y: scroll;
scroll-behavior: smooth;
}
html[theme='dark'],

View File

@@ -140,7 +140,7 @@
</span>
</div>
<div class="w-fit">
<insights-box type="'slim'" header="'From 2.18 on, you can filter this view by namespace.'" insight-close-id="'k8s-namespace-filtering'"></insights-box>
<helm-insights-box></helm-insights-box>
</div>
</div>
</div>

View File

@@ -21,7 +21,8 @@
</div>
<div class="w-full">
<div class="mb-2 small text-muted"
>Select the Helm chart to use. Bring further Helm charts into your selection list via <a ui-sref="portainer.account">User settings - Helm repositories</a>.</div
>Select the Helm chart to use. Bring further Helm charts into your selection list via
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
>
<beta-alert
is-html="true"

View File

@@ -38,6 +38,7 @@ class KubernetesConfigMapConverter {
res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
res.CreationDate = data.metadata.creationTimestamp;
res.Yaml = yaml ? yaml.data : '';
res.Labels = data.metadata.labels;
res.Data = _.concat(
_.map(data.data, (value, key) => {
@@ -98,6 +99,7 @@ class KubernetesConfigMapConverter {
res.metadata.uid = data.Id;
res.metadata.name = data.Name;
res.metadata.namespace = data.Namespace;
res.metadata.labels = data.Labels || {};
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = data.ConfigurationOwner;
_.forEach(data.Data, (entry) => {
if (entry.IsBinary) {

View File

@@ -21,6 +21,7 @@ class KubernetesConfigurationConverter {
if (secret.Annotations) {
res.ServiceAccountName = secret.Annotations['kubernetes.io/service-account.name'];
}
res.Labels = secret.Labels;
return res;
}
@@ -37,6 +38,7 @@ class KubernetesConfigurationConverter {
});
res.data = res.Data;
res.ConfigurationOwner = configMap.ConfigurationOwner;
res.Labels = configMap.Labels;
return res;
}
}

View File

@@ -39,6 +39,7 @@ class KubernetesSecretConverter {
res.metadata.name = secret.Name;
res.metadata.namespace = secret.Namespace;
res.type = secret.Type;
res.metadata.labels = secret.Labels || {};
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = secret.ConfigurationOwner;
let annotation = '';
@@ -67,6 +68,7 @@ class KubernetesSecretConverter {
res.Name = payload.metadata.name;
res.Namespace = payload.metadata.namespace;
res.Type = payload.type;
res.Labels = payload.metadata.labels || {};
res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
res.CreationDate = payload.metadata.creationTimestamp;
res.Annotations = payload.metadata.annotations;

View File

@@ -21,6 +21,7 @@ const _KubernetesConfigMap = Object.freeze({
Yaml: '',
ConfigurationOwner: '',
Data: [],
Labels: {},
});
export class KubernetesConfigMap {

View File

@@ -14,6 +14,7 @@ const _KubernetesConfigurationFormValues = Object.freeze({
IsSimple: true,
ServiceAccountName: '',
Type: KubernetesSecretTypeOptions.OPAQUE.value,
Labels: {},
});
export class KubernetesConfigurationFormValues {

View File

@@ -12,6 +12,7 @@ const _KubernetesApplicationSecret = Object.freeze({
Data: [],
SecretType: '',
Annotations: [],
Labels: {},
});
export class KubernetesApplicationSecret {

View File

@@ -26,6 +26,7 @@ import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable';
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName';
import { StackNameLabelInsight } from '@/react/kubernetes/DeployView/StackName/StackNameLabelInsight';
import { SecretsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection';
import { configurationsValidationSchema } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/configurationValidationSchema';
import { ConfigMapsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection';
@@ -58,6 +59,7 @@ import { deploymentTypeValidation } from '@/react/kubernetes/applications/compon
import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection';
import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection';
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema';
import { HelmInsightsBox } from '@/react/kubernetes/applications/ListView/ApplicationsDatatable/HelmInsightsBox';
import { applicationsModule } from './applications';
@@ -88,6 +90,7 @@ export const ngModule = angular
'value',
])
)
.component('helmInsightsBox', r2a(HelmInsightsBox, []))
.component(
'namespaceAccessUsersSelector',
r2a(NamespaceAccessUsersSelector, [
@@ -139,9 +142,13 @@ export const ngModule = angular
),
{ stackName: 'setStackName' }
),
['setStackName', 'stackName', 'stacks', 'inputClassName']
['setStackName', 'stackName', 'stacks', 'inputClassName', 'textTip']
)
)
.component(
'stackNameLabelInsight',
r2a(withUIRouter(withCurrentUser(StackNameLabelInsight)), [])
)
.component(
'editYamlFormSection',
r2a(withUIRouter(withReactQuery(withCurrentUser(EditYamlFormSection))), [

View File

@@ -82,10 +82,12 @@ class KubernetesConfigurationService {
if (formValues.Kind === KubernetesConfigurationKinds.CONFIGMAP) {
const configMap = KubernetesConfigMapConverter.configurationFormValuesToConfigMap(formValues);
configMap.ConfigurationOwner = configuration.ConfigurationOwner;
configMap.Labels = configuration.Labels;
await this.KubernetesConfigMapService.update(configMap);
} else {
const secret = KubernetesSecretConverter.configurationFormValuesToSecret(formValues);
secret.ConfigurationOwner = configuration.ConfigurationOwner;
secret.Labels = configuration.Labels;
await this.KubernetesSecretService.update(secret);
}
}

View File

@@ -135,11 +135,11 @@
class="btn btn-sm btn-primary"
ng-click="ctrl.updateApplicationViaWebEditor()"
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT || ctrl.state.updateWebEditorInProgress"
ng-disabled="!kubernetesApplicationCreationForm.$valid || !ctrl.state.isEditorDirty || ctrl.state.updateWebEditorInProgress"
ng-disabled="ctrl.isUpdateApplicationViaWebEditorButtonDisabled() || !kubernetesApplicationCreationForm.$valid"
style="margin-top: 7px; margin-left: 0"
button-spinner="ctrl.state.updateWebEditorInProgress"
>
<span ng-show="!ctrl.state.updateWebEditorInProgress">Update the application</span>
<span ng-show="!ctrl.state.updateWebEditorInProgress">Update application</span>
<span ng-show="ctrl.state.updateWebEditorInProgress">Update in progress...</span>
</button>
</div>
@@ -169,6 +169,7 @@
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
stack-name="ctrl.formValues.StackName"
set-stack-name="(ctrl.onChangeStackName)"
text-tip="'Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use the application name.'"
stacks="ctrl.stacks"
input-class-name="'col-lg-10 col-sm-9'"
></kube-stack-name>
@@ -226,9 +227,10 @@
<div ng-if="ctrl.formValues.ResourcePool">
<!-- #region STACK -->
<kube-stack-name
ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality"
stack-name="ctrl.formValues.StackName"
set-stack-name="(ctrl.onChangeStackName)"
text-tip="'Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use the application name.'"
stacks="ctrl.stacks"
input-class-name="'col-lg-10 col-sm-9'"
></kube-stack-name>
@@ -403,7 +405,7 @@
class="btn btn-sm btn-primary"
ng-click="ctrl.updateApplicationViaWebEditor()"
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT || ctrl.state.updateWebEditorInProgress"
ng-disabled="!kubernetesApplicationCreationForm.$valid || !ctrl.state.isEditorDirty || ctrl.state.updateWebEditorInProgress"
ng-disabled="ctrl.isUpdateApplicationViaWebEditorButtonDisabled() || !kubernetesApplicationCreationForm.$valid"
style="margin-top: 7px; margin-left: 0"
button-spinner="ctrl.state.updateWebEditorInProgress"
>

View File

@@ -38,6 +38,7 @@ class KubernetesCreateApplicationController {
$async,
$state,
$timeout,
$window,
Notifications,
Authentication,
KubernetesResourcePoolService,
@@ -58,6 +59,7 @@ class KubernetesCreateApplicationController {
this.$async = $async;
this.$state = $state;
this.$timeout = $timeout;
this.$window = $window;
this.Notifications = Notifications;
this.Authentication = Authentication;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
@@ -157,6 +159,7 @@ class KubernetesCreateApplicationController {
this.refreshReactComponent = this.refreshReactComponent.bind(this);
this.onChangeNamespaceName = this.onChangeNamespaceName.bind(this);
this.canSupportSharedAccess = this.canSupportSharedAccess.bind(this);
this.isUpdateApplicationViaWebEditorButtonDisabled = this.isUpdateApplicationViaWebEditorButtonDisabled.bind(this);
this.$scope.$watch(
() => this.formValues,
@@ -255,7 +258,7 @@ class KubernetesCreateApplicationController {
{ stackFile: this.stackFileContent, stackName: this.formValues.StackName }
);
this.state.isEditorDirty = false;
await this.$state.reload(this.$state.current);
this.$window.location.reload();
} catch (err) {
this.Notifications.error('Failure', err, 'Failed redeploying application');
} finally {
@@ -290,7 +293,7 @@ class KubernetesCreateApplicationController {
onAutoScaleChange(values) {
return this.$async(async () => {
// when enabling the auto scaler, set the default values
if (!this.oldFormValues.AutoScaler.isUsed && values.isUsed) {
if (!this.formValues.AutoScaler.isUsed && values.isUsed) {
this.formValues.AutoScaler = {
isUsed: values.isUsed,
minReplicas: 1,
@@ -643,6 +646,10 @@ class KubernetesCreateApplicationController {
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable;
}
isUpdateApplicationViaWebEditorButtonDisabled() {
return (this.savedFormValues.StackName === this.formValues.StackName && !this.state.isEditorDirty) || this.state.updateWebEditorInProgress;
}
isExternalApplication() {
if (this.application) {
return KubernetesApplicationHelper.isExternalApplication(this.application);

View File

@@ -159,6 +159,7 @@ class KubernetesConfigMapController {
this.formValues.Type = this.configuration.Type;
this.formValues.Kind = this.configuration.Kind;
this.oldDataYaml = this.formValues.DataYaml;
this.formValues.Labels = this.configuration.Labels;
return this.configuration;
} catch (err) {

View File

@@ -155,6 +155,7 @@ class KubernetesSecretController {
this.formValues.Type = this.configuration.Type;
this.formValues.Kind = this.configuration.Kind;
this.oldDataYaml = this.formValues.DataYaml;
this.formValues.Labels = this.configuration.Labels;
return this.configuration;
} catch (err) {

View File

@@ -47,6 +47,7 @@
ng-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"
class="form-control"
ng-model="ctrl.formValues.Namespace"
ng-change="ctrl.onChangeNamespace()"
ng-options="namespace.Name as namespace.Name for namespace in ctrl.namespaces"
></select>
<span ng-if="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM" class="small text-muted pt-[7px]"
@@ -85,11 +86,15 @@
</div>
</div>
<div class="w-fit mb-4">
<stack-name-label-insight></stack-name-label-insight>
</div>
<kube-stack-name
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"
stack-name="ctrl.formValues.StackName"
set-stack-name="(ctrl.setStackName)"
is-admin="ctrl.currentUser.isAdmin"
stacks="ctrl.stacks"
></kube-stack-name>
<!-- !namespace -->

View File

@@ -15,7 +15,7 @@ import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templat
class KubernetesDeployController {
/* @ngInject */
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService) {
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
this.$async = $async;
this.$state = $state;
this.$window = $window;
@@ -24,6 +24,7 @@ class KubernetesDeployController {
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.StackService = StackService;
this.CustomTemplateService = CustomTemplateService;
this.KubernetesApplicationService = KubernetesApplicationService;
this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
@@ -78,6 +79,8 @@ class KubernetesDeployController {
Name: '',
};
this.stacks = [];
this.ManifestDeployTypes = KubernetesDeployManifestTypes;
this.BuildMethods = KubernetesDeployBuildMethods;
@@ -92,6 +95,15 @@ class KubernetesDeployController {
this.onChangeDeployType = this.onChangeDeployType.bind(this);
this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this);
this.setStackName = this.setStackName.bind(this);
this.onChangeNamespace = this.onChangeNamespace.bind(this);
}
onChangeNamespace() {
return this.$async(async () => {
const applications = await this.KubernetesApplicationService.get(this.formValues.Namespace);
const stacks = _.map(applications, (item) => item.StackName).filter((item) => item !== '');
this.stacks = _.uniq(stacks);
});
}
onSelectHelmChart(chart) {
@@ -377,6 +389,7 @@ class KubernetesDeployController {
}
}
this.onChangeNamespace();
this.state.viewReady = true;
this.$window.onbeforeunload = () => {

View File

@@ -64,7 +64,7 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
this.$onInit = $onInit;
function $onInit() {
var isAdmin = Authentication.isAdmin();
var isAdmin = Authentication.isPureAdmin();
ctrl.isAdmin = isAdmin;
if (isAdmin) {

View File

@@ -25,7 +25,7 @@ export const settingsModule = angular
)
.component(
'applicationSettingsPanel',
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess'])
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess', 'settings'])
)
.component(
'sslSettingsPanel',
@@ -38,5 +38,5 @@ export const settingsModule = angular
)
.component(
'kubeSettingsPanel',
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), [])
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), ['settings'])
).name;

View File

@@ -1,4 +1,5 @@
import { getCurrentUser } from '../users/queries/useLoadCurrentUser';
import * as userHelpers from '../users/user.helpers';
import { clear as clearSessionStorage } from './session-storage';
const DEFAULT_USER = 'admin';
@@ -25,6 +26,9 @@ angular.module('portainer.app').factory('Authentication', [
service.isAuthenticated = isAuthenticated;
service.getUserDetails = getUserDetails;
service.isAdmin = isAdmin;
service.isEdgeAdmin = isEdgeAdmin;
service.isPureAdmin = isPureAdmin;
service.hasAuthorizations = hasAuthorizations;
async function initAsync() {
try {
@@ -120,8 +124,36 @@ angular.module('portainer.app').factory('Authentication', [
return login(DEFAULT_USER, DEFAULT_PASSWORD);
}
// To avoid creating divergence between CE and EE
// isAdmin checks if the user is a portainer admin or edge admin
function isEdgeAdmin() {
const environment = EndpointProvider.currentEndpoint();
return userHelpers.isEdgeAdmin({ Role: user.role }, environment);
}
/**
* @deprecated use Authentication.isAdmin instead
*/
function isAdmin() {
return !!user && user.role === 1;
return isEdgeAdmin();
}
// To avoid creating divergence between CE and EE
// isPureAdmin checks if the user is portainer admin only
function isPureAdmin() {
return userHelpers.isPureAdmin({ Role: user.role });
}
function hasAuthorizations(authorizations) {
const endpointId = EndpointProvider.endpointID();
if (isAdmin()) {
return true;
}
if (!user.endpointAuthorizations || !user.endpointAuthorizations[endpointId]) {
return false;
}
const userEndpointAuthorizations = user.endpointAuthorizations[endpointId];
return authorizations.some((authorization) => userEndpointAuthorizations[authorization]);
}
if (process.env.NODE_ENV === 'development') {

View File

@@ -1,9 +1,9 @@
import { useQuery } from 'react-query';
import { TeamRole, TeamMembership } from '@/react/portainer/users/teams/types';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { User, UserId } from './types';
import { isAdmin } from './user.helpers';
import { getUserMemberships, getUsers } from './user.service';
interface UseUserMembershipOptions<TSelect> {
@@ -22,14 +22,21 @@ export function useUserMembership<TSelect = TeamMembership[]>(
);
}
export function useIsTeamLeader(user: User) {
export function useIsCurrentUserTeamLeader() {
const { user } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const query = useUserMembership(user.Id, {
enabled: !isAdmin(user),
enabled: !isAdminQuery.isLoading && !isAdminQuery.isAdmin,
select: (memberships) =>
memberships.some((membership) => membership.Role === TeamRole.Leader),
});
return isAdmin(user) ? true : query.data;
if (isAdminQuery.isLoading) {
return false;
}
return isAdminQuery.isAdmin ? true : !!query.data;
}
export function useUsers<T = User[]>(

View File

@@ -7,6 +7,7 @@ export { type UserId };
export enum Role {
Admin = 1,
Standard,
EdgeAdmin,
}
interface AuthorizationMap {

View File

@@ -1,9 +1,30 @@
import { Environment } from '@/react/portainer/environments/types';
import { isEdgeEnvironment } from '@/react/portainer/environments/utils';
import { Role, User } from './types';
export function filterNonAdministratorUsers(users: User[]) {
return users.filter((user) => user.Role !== Role.Admin);
}
export function isAdmin(user?: User): boolean {
return !!user && user.Role === 1;
type UserLike = Pick<User, 'Role'>;
// To avoid creating divergence between CE and EE
// isAdmin checks if the user is portainer admin or edge admin
export function isEdgeAdmin(
user: UserLike | undefined,
environment?: Pick<Environment, 'Type'> | null
): boolean {
return (
isPureAdmin(user) ||
(user?.Role === Role.EdgeAdmin &&
(!environment || isEdgeEnvironment(environment.Type)))
);
}
// To avoid creating divergence between CE and EE
// isPureAdmin checks only if the user is portainer admin
// See bouncer.IsAdmin and bouncer.PureAdminAccess
export function isPureAdmin(user?: UserLike): boolean {
return !!user && user.Role === Role.Admin;
}

View File

@@ -131,48 +131,150 @@
</span>
</div>
</div>
<!-- !note -->
<box-selector slim="true" options="restoreOptions" value="formValues.restoreFormType" on-change="(onChangeRestoreType)" radio-name="'restore-type'"></box-selector>
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted"> You can upload a backup file from your computer. </span>
<div ng-if="formValues.restoreFormType === RESTORE_FORM_TYPES.FILE">
<!-- note -->
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted"> You can upload a backup file from your computer. </span>
</div>
</div>
</div>
<!-- !note -->
<!-- select-file-input -->
<div class="form-group">
<div class="col-sm-12 vertical-center">
<button
class="btn btn-sm btn-primary"
ngf-select
accept=".gz,.encrypted"
ngf-accept="'application/x-tar,application/x-gzip'"
ng-model="formValues.BackupFile"
auto-focus
data-cy="init-selectBackupFileButton"
>Select file</button
>
<span class="space-left vertical-center">
{{ formValues.BackupFile.name }}
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!formValues.BackupFile"></pr-icon>
</span>
<!-- !note -->
<!-- select-file-input -->
<div class="form-group">
<div class="col-sm-12 vertical-center">
<button
class="btn btn-sm btn-primary"
ngf-select
accept=".gz,.encrypted"
ngf-accept="'application/x-tar,application/x-gzip'"
ng-model="formValues.BackupFile"
auto-focus
data-cy="init-selectBackupFileButton"
>Select file</button
>
<span class="space-left vertical-center">
{{ formValues.BackupFile.name }}
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!formValues.BackupFile"></pr-icon>
</span>
</div>
</div>
<!-- password-input -->
<div class="form-group">
<label for="password" class="col-sm-3 control-label text-left">
Password
<portainer-tooltip
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
></portainer-tooltip>
</label>
<div class="col-sm-4">
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
</div>
</div>
<!-- !password-input -->
</div>
<!-- !select-file-input -->
<!-- password-input -->
<div class="form-group">
<label for="password" class="col-sm-3 control-label text-left">
Password
<portainer-tooltip
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
></portainer-tooltip>
</label>
<div class="col-sm-4">
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
<div class="limited-be-content" ng-if="formValues.restoreFormType === RESTORE_FORM_TYPES.S3">
<!-- Access key id -->
<div class="form-group">
<label for="access_key_id" class="col-sm-3 control-label text-left">Access key ID</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="access_key_id" name="access_key_id" ng-model="formValues.AccessKeyId" required data-cy="init-accessKeyIdInput" />
</div>
</div>
<!-- !Access key id -->
<!-- Secret access key -->
<div class="form-group">
<label for="secret_access_key" class="col-sm-3 control-label text-left">Secret access key</label>
<div class="col-sm-9">
<input
type="password"
class="form-control"
id="secret_access_key"
name="secret_access_key"
ng-model="formValues.SecretAccessKey"
required
data-cy="init-secretAccessKeyInput"
/>
</div>
</div>
<!-- !Secret access key -->
<!-- Region -->
<div class="form-group">
<label for="backup-s3-region" class="col-sm-3 control-label text-left">Region</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
placeholder="default region is us-east-1 if left empty"
id="backup-s3-region"
name="backup-s3-region"
ng-model="formValues.Region"
data-cy="init-s3RegionInput"
/>
</div>
</div>
<!-- !Region -->
<!-- Bucket name -->
<div class="form-group">
<label for="bucket_name" class="col-sm-3 control-label text-left">Bucket name</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="bucket_name" name="bucket_name" ng-model="formValues.BucketName" required data-cy="init-bucketNameInput" />
</div>
</div>
<!-- !Bucket name -->
<!-- S3 Compatible Host -->
<div class="form-group">
<label for="s3-compatible-host" class="col-sm-3 control-label text-left">
S3 Compatible Host
<portainer-tooltip message="'Hostname of a S3 service'"></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
id="s3-compatible-host"
name="s3-compatible-host"
ng-model="formValues.S3CompatibleHost"
placeholder="leave empty for AWS S3"
data-cy="init-s3CompatibleHostInput"
/>
</div>
</div>
<!-- !S3 Compatible Host -->
<!-- Filename -->
<div class="form-group">
<label for="backup-s3-filename" class="col-sm-3 control-label text-left">Filename</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
id="backup-s3-filename"
name="backup-s3-filename"
ng-model="formValues.Filename"
required
data-cy="init-backupFilenameInput"
/>
</div>
</div>
<!-- !Filename -->
<!-- password-input -->
<div class="form-group">
<label for="password" class="col-sm-3 control-label text-left">
Password
<portainer-tooltip
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
></portainer-tooltip>
</label>
<div class="col-sm-4">
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
</div>
</div>
<!-- !password-input -->
</div>
<!-- !password-input -->
<!-- note -->
<div class="form-group">
<div class="col-sm-12">

View File

@@ -17,12 +17,14 @@ angular.module('portainer.app').controller('InitAdminController', [
$scope.uploadBackup = uploadBackup;
$scope.logo = StateManager.getState().application.logo;
$scope.RESTORE_FORM_TYPES = { S3: 's3', FILE: 'file' };
$scope.formValues = {
Username: 'admin',
Password: '',
ConfirmPassword: '',
enableTelemetry: process.env.NODE_ENV === 'production',
restoreFormType: $scope.RESTORE_FORM_TYPES.FILE,
};
$scope.state = {

View File

@@ -38,16 +38,7 @@ export function react2angular<T, U extends PropNames<T>[]>(
Component: React.ComponentType<T & JSX.IntrinsicAttributes>,
propNames: U & ([PropNames<T>] extends [U[number]] ? unknown : PropNames<T>)
): IComponentOptions & { name: string } {
const bindings = Object.fromEntries(
propNames.map((key) => {
// use two way binding for errors, to avoid shifting the layout from errors going between undefined <-> some value when using inputs.
// See https://portainer.atlassian.net/browse/EE-6570 for more context
if (key === 'errors') {
return [key, '='];
}
return [key, '<'];
})
);
const bindings = Object.fromEntries(propNames.map((key) => [key, '<']));
return {
bindings,

View File

@@ -17,6 +17,8 @@ interface FormFieldProps<TValue> {
type WithFormFieldProps<TProps, TValue> = TProps & FormFieldProps<TValue>;
type ValidationResult<T> = FormikErrors<T> | undefined;
/**
* This utility function is used for wrapping React components with form validation.
* When used inside an Angular form, it sets the form to invalid if the component values are invalid.
@@ -109,6 +111,7 @@ function createFormValidatorController<TFormModel, TData = never>(
this.handleChange = this.handleChange.bind(this);
this.runValidation = this.runValidation.bind(this);
this.validate = this.validate.bind(this);
}
async handleChange(newValues: TFormModel) {
@@ -123,21 +126,31 @@ function createFormValidatorController<TFormModel, TData = never>(
this.form?.$setValidity('form', true, this.form);
const schema = schemaBuilder(this.validationData);
this.errors = undefined;
const errors = await (isPrimitive
? validateForm<{ value: TFormModel }>(
() => object({ value: schema }),
{ value }
).then((r) => r?.value)
: validateForm<TFormModel>(() => schema, value));
this.errors = await this.validate(schema, value, isPrimitive);
if (errors && Object.keys(errors).length > 0) {
this.errors = errors as FormikErrors<TFormModel> | undefined;
if (this.errors && Object.keys(this.errors).length > 0) {
this.form?.$setValidity('form', false, this.form);
}
});
}
async validate(
schema: SchemaOf<TFormModel>,
value: TFormModel,
isPrimitive: boolean
): Promise<ValidationResult<TFormModel>> {
return this.$async(async () => {
if (isPrimitive) {
const result = await validateForm<{ value: TFormModel }>(
() => object({ value: schema }),
{ value }
);
return result?.value as ValidationResult<TFormModel>;
}
return validateForm<TFormModel>(() => schema, value);
});
}
async $onChanges(changes: {
values?: { currentValue: TFormModel };
validationData?: { currentValue: TData };

View File

@@ -29,15 +29,15 @@ test('submit button should be disabled when name or image is missing', async ()
expect(button).toBeDisabled();
const nameInput = getByLabelText(/name/i);
userEvent.type(nameInput, 'name');
await userEvent.type(nameInput, 'name');
const imageInput = getByLabelText(/image/i);
userEvent.type(imageInput, 'image');
await userEvent.type(imageInput, 'image');
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
expect(nameInput).toHaveValue('name');
userEvent.clear(nameInput);
await userEvent.clear(nameInput);
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
});

View File

@@ -4,7 +4,7 @@ import { Plus } from 'lucide-react';
import { ContainerInstanceFormValues } from '@/react/azure/types';
import * as notifications from '@/portainer/services/notifications';
import { useUser } from '@/react/hooks/useUser';
import { useCurrentUser } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control/AccessControlForm';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@@ -24,7 +24,7 @@ import { useCreateInstanceMutation } from './useCreateInstanceMutation';
export function CreateContainerInstanceForm() {
const environmentId = useEnvironmentId();
const { isAdmin } = useUser();
const { isPureAdmin } = useCurrentUser();
const { providers, subscriptions, resourceGroups, isLoading } =
useLoadFormState(environmentId);
@@ -49,7 +49,7 @@ export function CreateContainerInstanceForm() {
return (
<Formik<ContainerInstanceFormValues>
initialValues={initialValues}
validationSchema={() => validationSchema(isAdmin)}
validationSchema={() => validationSchema(isPureAdmin)}
onSubmit={onSubmit}
validateOnMount
validateOnChange

View File

@@ -37,7 +37,7 @@ export function useFormState(
resourceGroups: Record<string, ResourceGroup[]> = {},
providers: Record<string, ProviderViewModel> = {}
) {
const { isAdmin, user } = useCurrentUser();
const { user, isPureAdmin } = useCurrentUser();
const subscriptionOptions = subscriptions.map((s) => ({
value: s.subscriptionId,
@@ -67,7 +67,7 @@ export function useFormState(
cpu: 1,
ports: [{ container: 80, host: 80, protocol: 'TCP' }],
allocatePublicIP: true,
accessControl: parseAccessControlFormData(isAdmin, user.Id),
accessControl: parseAccessControlFormData(isPureAdmin, user.Id),
};
return {

View File

@@ -0,0 +1,26 @@
import { render } from '@/react-tools/test-utils';
import { Badge } from './Badge';
test('should render a Badge component with default type', () => {
const { getByText } = render(<Badge>Default Badge</Badge>);
const badgeElement = getByText('Default Badge');
expect(badgeElement).toBeInTheDocument();
expect(badgeElement).toHaveClass('text-blue-9 bg-blue-2');
});
test('should render a Badge component with custom type', () => {
const { getByText } = render(<Badge type="success">Success Badge</Badge>);
const badgeElement = getByText('Success Badge');
expect(badgeElement).toBeInTheDocument();
expect(badgeElement).toHaveClass('text-success-9 bg-success-2');
});
test('should render a Badge component with custom className', () => {
const { getByText } = render(
<Badge className="custom-class">Custom Badge</Badge>
);
const badgeElement = getByText('Custom Badge');
expect(badgeElement).toBeInTheDocument();
expect(badgeElement).toHaveClass('custom-class');
});

View File

@@ -0,0 +1,90 @@
import { FormikErrors } from 'formik';
import { ComponentProps } from 'react';
import { HttpResponse } from 'msw';
import { renderWithQueryClient, fireEvent } from '@/react-tools/test-utils';
import { http, server } from '@/setup-tests/server';
import { ImageConfigFieldset } from './ImageConfigFieldset';
import { Values } from './types';
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: 1 },
})),
}));
it('should render SimpleForm when useRegistry is true', () => {
const { getByText } = render({ values: { useRegistry: true } });
expect(getByText('Advanced mode')).toBeInTheDocument();
});
it('should render AdvancedForm when useRegistry is false', () => {
const { getByText } = render({ values: { useRegistry: false } });
expect(getByText('Simple mode')).toBeInTheDocument();
});
it('should call setFieldValue with useRegistry set to false when "Advanced mode" button is clicked', () => {
const setFieldValue = vi.fn();
const { getByText } = render({
values: { useRegistry: true },
setFieldValue,
});
fireEvent.click(getByText('Advanced mode'));
expect(setFieldValue).toHaveBeenCalledWith('useRegistry', false);
});
it('should call setFieldValue with useRegistry set to true when "Simple mode" button is clicked', () => {
const setFieldValue = vi.fn();
const { getByText } = render({
values: { useRegistry: false },
setFieldValue,
});
fireEvent.click(getByText('Simple mode'));
expect(setFieldValue).toHaveBeenCalledWith('useRegistry', true);
});
function render({
values = {
useRegistry: true,
registryId: 123,
image: '',
},
errors = {},
setFieldValue = vi.fn(),
onChangeImage = vi.fn(),
onRateLimit = vi.fn(),
}: {
values?: Partial<Values>;
errors?: FormikErrors<Values>;
setFieldValue?: ComponentProps<typeof ImageConfigFieldset>['setFieldValue'];
onChangeImage?: ComponentProps<typeof ImageConfigFieldset>['onChangeImage'];
onRateLimit?: ComponentProps<typeof ImageConfigFieldset>['onRateLimit'];
} = {}) {
server.use(
http.get('/api/registries/:id', () => HttpResponse.json({})),
http.get('/api/endpoints/:id', () => HttpResponse.json({}))
);
return renderWithQueryClient(
<ImageConfigFieldset
values={{
useRegistry: true,
registryId: 123,
image: '',
...values,
}}
errors={errors}
setFieldValue={setFieldValue}
onChangeImage={onChangeImage}
onRateLimit={onRateLimit}
/>
);
}

View File

@@ -66,7 +66,7 @@ function RateLimitsInner({
environment: Environment;
}) {
const pullRateLimits = useRateLimits(registryId, environment, onRateLimit);
const { isAdmin } = useCurrentUser();
const { isPureAdmin } = useCurrentUser();
if (!pullRateLimits) {
return null;
@@ -88,7 +88,7 @@ function RateLimitsInner({
</>
) : (
<>
{isAdmin ? (
{isPureAdmin ? (
<>
You are currently using an anonymous account to pull images
from DockerHub and will be limited to 100 pulls every 6

View File

@@ -42,7 +42,7 @@ test('should call onSelect when clicked with id', async () => {
const { findByText } = renderComponent(options, options[1].id, onSelect);
const heading = await findByText(options[0].label);
userEvent.click(heading);
await userEvent.click(heading);
expect(onSelect).toHaveBeenCalledWith(options[0].id);
});

View File

@@ -2,19 +2,23 @@ import { createContext, PropsWithChildren, useContext } from 'react';
const Context = createContext<null | boolean>(null);
Context.displayName = 'WidgetContext';
export function useWidgetContext() {
const context = useContext(Context);
if (context == null) {
throw new Error('Should be inside a Widget component');
}
}
export function Widget({ children }: PropsWithChildren<unknown>) {
export function Widget({
children,
id,
}: PropsWithChildren<{
id?: string;
}>) {
return (
<Context.Provider value>
<div className="widget">{children}</div>
<div id={id} className="widget">
{children}
</div>
</Context.Provider>
);
}

View File

@@ -58,6 +58,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
emptyContentLabel?: string;
title?: string;
titleIcon?: IconProps['icon'];
titleId?: string;
initialTableState?: Partial<TableState>;
isLoading?: boolean;
description?: ReactNode;
@@ -78,6 +79,7 @@ export function Datatable<D extends DefaultType>({
getRowId = defaultGetRowId,
isRowSelectable = () => true,
title,
titleId,
titleIcon,
emptyContentLabel,
initialTableState = {},
@@ -172,6 +174,7 @@ export function Datatable<D extends DefaultType>({
onSearchChange={handleSearchBarChange}
searchValue={settings.search}
title={title}
titleId={titleId}
titleIcon={titleIcon}
description={description}
renderTableActions={() => renderTableActions(selectedItems)}

View File

@@ -13,6 +13,7 @@ type Props = {
renderTableSettings?(): ReactNode;
renderTableActions?(): ReactNode;
description?: ReactNode;
titleId?: string;
};
export function DatatableHeader({
@@ -23,6 +24,7 @@ export function DatatableHeader({
title,
titleIcon,
description,
titleId,
}: Props) {
if (!title) {
return null;
@@ -37,7 +39,12 @@ export function DatatableHeader({
);
return (
<Table.Title label={title} icon={titleIcon} description={description}>
<Table.Title
id={titleId}
label={title}
icon={titleIcon}
description={description}
>
{searchBar}
{tableActions}
{tableTitleSettings}

View File

@@ -8,6 +8,7 @@ interface Props {
label: string;
description?: ReactNode;
className?: string;
id?: string;
}
export function TableTitle({
@@ -16,10 +17,11 @@ export function TableTitle({
children,
description,
className,
id,
}: PropsWithChildren<Props>) {
return (
<>
<div className={clsx('toolBar flex-col', className)}>
<div className={clsx('toolBar flex-col', className)} id={id}>
<div className="flex w-full items-center gap-1 p-0">
<div className="toolBarTitle">
{icon && (

View File

@@ -10,7 +10,7 @@ import { Values } from './BaseForm';
export function toViewModel(
config: ContainerResponse,
isAdmin: boolean,
isPureAdmin: boolean,
currentUserId: UserId,
nodeName: string,
image: Values['image'],
@@ -18,7 +18,7 @@ export function toViewModel(
): Values {
// accessControl shouldn't be copied to new container
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
const accessControl = parseAccessControlFormData(isPureAdmin, currentUserId);
if (config.Portainer?.ResourceControl?.Public) {
accessControl.ownership = ResourceControlOwnership.PUBLIC;
@@ -38,11 +38,11 @@ export function toViewModel(
}
export function getDefaultViewModel(
isAdmin: boolean,
isPureAdmin: boolean,
currentUserId: UserId,
nodeName: string
): Values {
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
const accessControl = parseAccessControlFormData(isPureAdmin, currentUserId);
return {
nodeName,

View File

@@ -2,7 +2,7 @@ import { Formik } from 'formik';
import { useRouter } from '@uirouter/react';
import { useEffect, useState } from 'react';
import { useCurrentUser, useIsEnvironmentAdmin } from '@/react/hooks/useUser';
import { useIsEdgeAdmin, useIsEnvironmentAdmin } from '@/react/hooks/useUser';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
@@ -48,7 +48,7 @@ function CreateForm() {
const environmentId = useEnvironmentId();
const router = useRouter();
const { trackEvent } = useAnalytics();
const { isAdmin } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const isEnvironmentAdmin = useIsEnvironmentAdmin();
const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false);
@@ -67,7 +67,7 @@ function CreateForm() {
const envQuery = useCurrentEnvironment();
const validationSchema = useValidation({
isAdmin,
isAdmin: isAdminQuery.isAdmin,
maxCpu,
maxMemory,
isDuplicating: initialValuesQuery?.isDuplicating,

View File

@@ -102,7 +102,7 @@ export function InnerForm({
}
errors={errors.volumes}
allowBindMounts={
isEnvironmentAdmin ||
isEnvironmentAdmin.authorized ||
environment.SecuritySettings
.allowBindMountsForRegularUsers
}
@@ -166,18 +166,18 @@ export function InnerForm({
setFieldValue(`resources.${field}`, value)
}
allowPrivilegedMode={
isEnvironmentAdmin ||
isEnvironmentAdmin.authorized ||
environment.SecuritySettings
.allowPrivilegedModeForRegularUsers
}
isDevicesFieldVisible={
isEnvironmentAdmin ||
isEnvironmentAdmin.authorized ||
environment.SecuritySettings
.allowDeviceMappingForRegularUsers
}
isInitFieldVisible={apiVersion >= 1.37}
isSysctlFieldVisible={
isEnvironmentAdmin ||
isEnvironmentAdmin.authorized ||
environment.SecuritySettings
.allowSysctlSettingForRegularUsers
}

View File

@@ -62,7 +62,8 @@ export function useInitialValues(submitting: boolean) {
params: { nodeName, from },
} = useCurrentStateAndParams();
const environmentId = useEnvironmentId();
const { isAdmin, user } = useCurrentUser();
const { user, isPureAdmin } = useCurrentUser();
const networksQuery = useNetworksForSelector();
const fromContainerQuery = useContainer(environmentId, from, {
@@ -85,7 +86,7 @@ export function useInitialValues(submitting: boolean) {
if (!from) {
return {
initialValues: defaultValues(isAdmin, user.Id, nodeName),
initialValues: defaultValues(isPureAdmin, user.Id, nodeName),
};
}
@@ -136,7 +137,7 @@ export function useInitialValues(submitting: boolean) {
env: envVarsTabUtils.toViewModel(fromContainer),
...baseFormUtils.toViewModel(
fromContainer,
isAdmin,
isPureAdmin,
user.Id,
nodeName,
imageConfig,
@@ -148,7 +149,7 @@ export function useInitialValues(submitting: boolean) {
}
function defaultValues(
isAdmin: boolean,
isPureAdmin: boolean,
currentUserId: UserId,
nodeName: string
): Values {
@@ -161,6 +162,6 @@ function defaultValues(
resources: resourcesTabUtils.getDefaultViewModel(),
capabilities: capabilitiesTabUtils.getDefaultViewModel(),
env: envVarsTabUtils.getDefaultViewModel(),
...baseFormUtils.getDefaultViewModel(isAdmin, currentUserId, nodeName),
...baseFormUtils.getDefaultViewModel(isPureAdmin, currentUserId, nodeName),
};
}

View File

@@ -1,4 +1,4 @@
import { render } from '@/react-tools/test-utils';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { UserContext } from '@/react/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
@@ -50,7 +50,7 @@ test('Non system networks should have a delete button', async () => {
async function renderComponent(isAdmin: boolean, network: DockerNetwork) {
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
const queries = render(
const queries = renderWithQueryClient(
<UserContext.Provider value={{ user }}>
<NetworkDetailsTable
network={network}

View File

@@ -1,7 +1,7 @@
import { Layers } from 'lucide-react';
import { Row } from '@tanstack/react-table';
import { useAuthorizations, useCurrentUser } from '@/react/hooks/useUser';
import { useAuthorizations, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Datatable } from '@@/datatables';
@@ -34,7 +34,7 @@ export function StacksDatatable({
}) {
const tableState = useTableState(settingsStore, tableKey);
useRepeater(tableState.autoRefreshRate, onReload);
const { isAdmin } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const canManageStacks = useAuthorizations([
'PortainerStackCreate',
'PortainerStackDelete',
@@ -58,7 +58,7 @@ export function StacksDatatable({
columns={columns}
dataset={dataset}
isRowSelectable={({ original: item }) =>
allowSelection(item, isAdmin, canManageStacks)
allowSelection(item, isAdminQuery.isAdmin, canManageStacks.authorized)
}
getRowId={(item) => item.Id.toString()}
initialTableState={{

View File

@@ -1,6 +1,6 @@
import { CellContext, Column } from '@tanstack/react-table';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
import { StackStatus } from '@/react/common/stacks/types';
import {
@@ -67,7 +67,7 @@ function NameCell({
}
function NameLink({ item }: { item: DecoratedStack }) {
const { isAdmin } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const name = item.Name;
@@ -87,7 +87,7 @@ function NameLink({ item }: { item: DecoratedStack }) {
);
}
if (!isAdmin && isOrphanedStack(item)) {
if (!isAdminQuery.isAdmin && isOrphanedStack(item)) {
return <>{name}</>;
}

View File

@@ -4,6 +4,7 @@ import { notifySuccess } from '@/portainer/services/notifications';
import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/queries/useDeleteEnvironmentsMutation';
import { Environment } from '@/react/portainer/environments/types';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { useIsPureAdmin } from '@/react/hooks/useUser';
import { Button } from '@@/buttons';
import { ModalType, openModal } from '@@/modals';
@@ -28,6 +29,7 @@ export function TableActions({
}: {
selectedRows: WaitingRoomEnvironment[];
}) {
const isPureAdmin = useIsPureAdmin();
const associateMutation = useAssociateDeviceMutation();
const removeMutation = useDeleteEnvironmentsMutation();
const licenseOverused = useLicenseOverused(selectedRows.length);
@@ -58,7 +60,9 @@ export function TableActions({
<span>
<Button
onClick={() => handleAssociateAndAssign(selectedRows)}
disabled={selectedRows.length === 0 || licenseOverused}
disabled={
selectedRows.length === 0 || licenseOverused || !isPureAdmin
}
color="secondary"
icon={CheckCircle}
>

View File

@@ -0,0 +1,28 @@
import { RawParams, useRouter } from '@uirouter/react';
import { useEffect } from 'react';
import { useCurrentUser } from './useUser';
type RedirectOptions = {
to: string;
params?: RawParams;
};
/**
* Redirects to the given route if the user is not a Portainer admin.
* @param to The route to redirect to (default is `'portainer.home'`).
* @param params The params to pass to the route.
*/
export function useAdminOnlyRedirect(
{ to, params }: RedirectOptions = { to: 'portainer.home' }
) {
const router = useRouter();
const { isPureAdmin } = useCurrentUser();
useEffect(() => {
if (!isPureAdmin) {
router.stateService.go(to, params);
}
}, [isPureAdmin, to, params, router.stateService]);
}

View File

@@ -1,13 +1,10 @@
import { useRouter } from '@uirouter/react';
import { useEffect } from 'react';
import { EnvironmentId } from '../portainer/environments/types';
import { useAuthorizations } from './useUser';
type AuthorizationOptions = {
authorizations: string | string[];
forceEnvironmentId?: EnvironmentId;
adminOnlyCE?: boolean;
};
@@ -19,24 +16,19 @@ type RedirectOptions = {
/**
* Redirects to the given route if the user is not authorized.
* @param authorizations The authorizations to check.
* @param forceEnvironmentId The environment id to use for the check.
* @param adminOnlyCE Whether to check only for admin authorizations in CE.
* @param adminOnlyCE Whether to allow non-admin users in CE.
* @param to The route to redirect to.
* @param params The params to pass to the route.
*/
export function useUnauthorizedRedirect(
{
authorizations,
forceEnvironmentId,
adminOnlyCE = false,
}: AuthorizationOptions,
{ authorizations, adminOnlyCE = false }: AuthorizationOptions,
{ to, params }: RedirectOptions
) {
const router = useRouter();
const isAuthorized = useAuthorizations(
authorizations,
forceEnvironmentId,
undefined,
adminOnlyCE
);

View File

@@ -7,11 +7,14 @@ import {
PropsWithChildren,
} from 'react';
import { isAdmin } from '@/portainer/users/user.helpers';
import { isEdgeAdmin, isPureAdmin } from '@/portainer/users/user.helpers';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { User } from '@/portainer/users/types';
import { useLoadCurrentUser } from '@/portainer/users/queries/useLoadCurrentUser';
import { useEnvironment } from '../portainer/environments/queries';
import { isBE } from '../portainer/feature-flags/feature-flags.service';
interface State {
user?: User;
}
@@ -39,32 +42,84 @@ export function useCurrentUser() {
return useMemo(
() => ({
user,
isAdmin: isAdmin(user),
isPureAdmin: isPureAdmin(user),
}),
[user]
);
}
export function useIsPureAdmin() {
const { isPureAdmin } = useCurrentUser();
return isPureAdmin;
}
/**
* Load the admin status of the user, (admin >= edge admin)
* @param forceEnvironmentId to force the environment id, used where the environment id can't be loaded from the router, like sidebar
* @returns query result with isLoading and isAdmin - isAdmin is true if the user edge admin or admin.
*/
export function useIsEdgeAdmin({
forceEnvironmentId,
noEnvScope,
}: {
forceEnvironmentId?: EnvironmentId;
noEnvScope?: boolean;
} = {}) {
const { user } = useCurrentUser();
const {
params: { endpointId },
} = useCurrentStateAndParams();
const envId = forceEnvironmentId || endpointId;
const envScope = typeof noEnvScope === 'boolean' ? !noEnvScope : !!envId;
const envQuery = useEnvironment(envScope ? envId : undefined);
if (!envScope) {
return { isLoading: false, isAdmin: isEdgeAdmin(user) };
}
if (envQuery.isLoading) {
return { isLoading: true, isAdmin: false };
}
return {
isLoading: false,
isAdmin: isEdgeAdmin(user, envQuery.data),
};
}
export function useAuthorizations(
authorizations: string | string[],
forceEnvironmentId?: EnvironmentId,
adminOnlyCE = false
) {
const { user } = useUser();
const { user } = useCurrentUser();
const {
params: { endpointId },
} = useCurrentStateAndParams();
const envQuery = useEnvironment(forceEnvironmentId || endpointId);
const isAdmin = useIsEdgeAdmin({ forceEnvironmentId });
if (!user) {
return false;
return { authorized: false, isLoading: false };
}
return hasAuthorizations(
user,
authorizations,
forceEnvironmentId || endpointId,
adminOnlyCE
);
if (envQuery.isLoading) {
return { authorized: false, isLoading: true };
}
if (isAdmin) {
return { authorized: true, isLoading: false };
}
if (!isBE && adminOnlyCE) {
return { authorized: false, isLoading: false };
}
return {
authorized: hasAuthorizations(user, authorizations, envQuery.data?.Id),
isLoading: false,
};
}
export function useIsEnvironmentAdmin({
@@ -81,24 +136,18 @@ export function useIsEnvironmentAdmin({
);
}
export function isEnvironmentAdmin(
user: User,
environmentId: EnvironmentId,
adminOnlyCE = true
) {
return hasAuthorizations(
user,
['EndpointResourcesAccess'],
environmentId,
adminOnlyCE
);
}
export function hasAuthorizations(
/**
* will return true if the user has the authorizations. assumes the user is authenticated and not an admin
* @param user
* @param authorizations
* @param environmentId
* @param adminOnlyCE
* @returns
*/
function hasAuthorizations(
user: User,
authorizations: string | string[],
environmentId?: EnvironmentId,
adminOnlyCE = false
environmentId?: EnvironmentId
) {
const authorizationsArray =
typeof authorizations === 'string' ? [authorizations] : authorizations;
@@ -107,26 +156,13 @@ export function hasAuthorizations(
return true;
}
if (process.env.PORTAINER_EDITION === 'CE') {
return !adminOnlyCE || isAdmin(user);
}
if (!environmentId) {
return false;
}
if (isAdmin(user)) {
return true;
}
const userEndpointAuthorizations =
user.EndpointAuthorizations?.[environmentId] || [];
if (
!user.EndpointAuthorizations ||
!user.EndpointAuthorizations[environmentId]
) {
return false;
}
const userEndpointAuthorizations = user.EndpointAuthorizations[environmentId];
return authorizationsArray.some(
(authorization) => userEndpointAuthorizations[authorization]
);

View File

@@ -1,8 +1,7 @@
import { useMemo } from 'react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { InsightsBox } from '@@/InsightsBox';
import { Link } from '@@/Link';
import { TextTip } from '@@/Tip/TextTip';
import { Tooltip } from '@@/Tip/Tooltip';
@@ -13,6 +12,7 @@ type Props = {
setStackName: (name: string) => void;
stacks?: string[];
inputClassName?: string;
textTip?: string;
};
export function StackName({
@@ -20,12 +20,16 @@ export function StackName({
setStackName,
stacks = [],
inputClassName,
textTip = "Enter or select a 'stack' name to group multiple deployments together, or else leave empty to ignore.",
}: Props) {
const { isAdmin } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const stackResults = useMemo(
() => stacks.filter((stack) => stack.includes(stackName ?? '')),
[stacks, stackName]
);
const { isAdmin } = isAdminQuery;
const tooltip = (
<>
You may specify a stack name to label resources that you want to group.
@@ -44,37 +48,10 @@ export function StackName({
</>
);
const insightsBoxContent = (
<>
The stack field below was previously labelled &apos;Name&apos; but, in
fact, it&apos;s always been the stack name (hence the relabelling).
{isAdmin && (
<>
<br />
Kubernetes Stacks functionality can be turned off entirely via{' '}
<Link to="portainer.settings" target="_blank">
Kubernetes Settings
</Link>
.
</>
)}
</>
);
return (
<>
<div className="w-fit mb-4">
<InsightsBox
type="slim"
header="Stack"
content={insightsBoxContent}
insightCloseId="k8s-stacks-name"
/>
</div>
<TextTip className="mb-4" color="blue">
Enter or select a &apos;stack&apos; name to group multiple deployments
together, or else leave empty to ignore.
{textTip}
</TextTip>
<div className="form-group">
<label

View File

@@ -0,0 +1,33 @@
import { useCurrentUser } from '@/react/hooks/useUser';
import { InsightsBox } from '@@/InsightsBox';
import { Link } from '@@/Link';
export function StackNameLabelInsight() {
const { isPureAdmin } = useCurrentUser();
const insightsBoxContent = (
<>
The stack field below was previously labelled &apos;Name&apos; but, in
fact, it&apos;s always been the stack name (hence the relabelling).
{isPureAdmin && (
<>
<br />
Kubernetes Stacks functionality can be turned off entirely via{' '}
<Link to="portainer.settings" target="_blank">
Kubernetes Settings
</Link>
.
</>
)}
</>
);
return (
<InsightsBox
type="slim"
header="Stack"
content={insightsBoxContent}
insightCloseId="k8s-stacks-name"
/>
);
}

View File

@@ -1,7 +1,7 @@
import { Plus, RefreshCw } from 'lucide-react';
import { FormikErrors } from 'formik';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { useEnvironment } from '@/react/portainer/environments/queries';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@@ -39,7 +39,8 @@ export function LoadBalancerServicesForm({
namespace,
isEditMode,
}: Props) {
const { isAdmin } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const environmentId = useEnvironmentId();
const { data: loadBalancerEnabled, ...loadBalancerEnabledQuery } =
useEnvironment(
@@ -47,6 +48,12 @@ export function LoadBalancerServicesForm({
(environment) => environment?.Kubernetes.Configuration.UseLoadBalancer
);
if (isAdminQuery.isLoading) {
return null;
}
const { isAdmin } = isAdminQuery;
const loadBalancerServiceCount = services.filter(
(service) => service.Type === 'LoadBalancer'
).length;

View File

@@ -0,0 +1,17 @@
import { InsightsBox } from '@@/InsightsBox';
export function HelmInsightsBox() {
return (
<InsightsBox
header="Helm option"
content={
<span>
From 2.20 and on, the Helm menu sidebar option has moved to the{' '}
<strong>Create from manifest screen</strong> - accessed via the button
above.
</span>
}
insightCloseId="k8s-helm"
/>
);
}

View File

@@ -8,9 +8,9 @@ import { createStore } from '@/react/kubernetes/datatables/default-kube-datatabl
import { ExpandableDatatable } from '@@/datatables/ExpandableDatatable';
import { useRepeater } from '@@/datatables/useRepeater';
import { useTableState } from '@@/datatables/useTableState';
import { InsightsBox } from '@@/InsightsBox';
import { KubernetesStack } from '../../types';
import { HelmInsightsBox } from '../ApplicationsDatatable/HelmInsightsBox';
import { columns } from './columns';
import { SubRows } from './SubRows';
@@ -88,11 +88,7 @@ export function ApplicationsStacksDatatable({
/>
<div className="w-fit">
<InsightsBox
type="slim"
header="From 2.18 on, you can filter this view by namespace."
insightCloseId="k8s-namespace-filtering"
/>
<HelmInsightsBox />
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import { FormikErrors } from 'formik';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { SwitchField } from '@@/form-components/SwitchField';
import { Link } from '@@/Link';
@@ -113,7 +113,13 @@ export function AutoScalingFormSection({
}
function NoMetricsServerWarning() {
const { isAdmin } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
if (isAdminQuery.isLoading) {
return null;
}
const { isAdmin } = isAdminQuery;
return (
<TextTip color="orange">
{isAdmin && (

View File

@@ -12,7 +12,6 @@ export function ConfigureView() {
useUnauthorizedRedirect(
{
authorizations: 'K8sClusterW',
forceEnvironmentId: environment?.Id,
adminOnlyCE: false,
},
{

View File

@@ -1,6 +1,8 @@
import { formatDate } from '@/portainer/filters/filters';
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
import { ConfigMapRowData } from '../types';
import { configurationOwnerUsernameLabel } from '../../../constants';
import { columnHelper } from './helper';
@@ -12,7 +14,8 @@ export const created = columnHelper.accessor((row) => getCreatedAtText(row), {
function getCreatedAtText(row: ConfigMapRowData) {
const owner =
row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
row.metadata?.labels?.[configurationOwnerUsernameLabel] ||
row.metadata?.labels?.[appOwnerLabel];
const date = formatDate(row.metadata?.creationTimestamp);
return owner ? `${date} by ${owner}` : date;
}

View File

@@ -1,11 +1,13 @@
import { CellContext } from '@tanstack/react-table';
import { Authorized } from '@/react/hooks/useUser';
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
import { Link } from '@@/Link';
import { Badge } from '@@/Badge';
import { ConfigMapRowData } from '../types';
import { configurationOwnerUsernameLabel } from '../../../constants';
import { columnHelper } from './helper';
@@ -16,8 +18,11 @@ export const name = columnHelper.accessor(
const isSystemToken = name?.includes('default-token-');
const isSystemConfigMap = isSystemToken || row.isSystem;
const hasConfigurationOwner =
!!row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
const hasConfigurationOwner = !!(
row.metadata?.labels?.[configurationOwnerUsernameLabel] ||
row.metadata?.labels?.[appOwnerLabel]
);
return `${name} ${isSystemConfigMap ? 'system' : ''} ${
!isSystemToken && !hasConfigurationOwner ? 'external' : ''
} ${!row.inUse && !isSystemConfigMap ? 'unused' : ''}`;
@@ -35,10 +40,10 @@ function Cell({ row }: CellContext<ConfigMapRowData, string>) {
const isSystemToken = name?.includes('default-token-');
const isSystemConfigMap = isSystemToken || row.original.isSystem;
const hasConfigurationOwner =
!!row.original.metadata?.labels?.[
'io.portainer.kubernetes.configuration.owner'
];
const hasConfigurationOwner = !!(
row.original.metadata?.labels?.[configurationOwnerUsernameLabel] ||
row.original.metadata?.labels?.[appOwnerLabel]
);
return (
<Authorized authorizations="K8sConfigMapsR" childrenUnauthorized={name}>

View File

@@ -1,6 +1,8 @@
import { formatDate } from '@/portainer/filters/filters';
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
import { SecretRowData } from '../types';
import { configurationOwnerUsernameLabel } from '../../../constants';
import { columnHelper } from './helper';
@@ -12,7 +14,8 @@ export const created = columnHelper.accessor((row) => getCreatedAtText(row), {
function getCreatedAtText(row: SecretRowData) {
const owner =
row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
row.metadata?.labels?.[configurationOwnerUsernameLabel] ||
row.metadata?.labels?.[appOwnerLabel];
const date = formatDate(row.metadata?.creationTimestamp);
return owner ? `${date} by ${owner}` : date;
}

View File

@@ -1,11 +1,13 @@
import { CellContext } from '@tanstack/react-table';
import { Authorized } from '@/react/hooks/useUser';
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
import { Link } from '@@/Link';
import { Badge } from '@@/Badge';
import { SecretRowData } from '../types';
import { configurationOwnerUsernameLabel } from '../../../constants';
import { columnHelper } from './helper';
@@ -18,8 +20,10 @@ export const name = columnHelper.accessor(
row.metadata?.annotations?.['portainer.io/registry.id'];
const isSystemSecret = isSystemToken || row.isSystem || isRegistrySecret;
const hasConfigurationOwner =
!!row.metadata?.labels?.['io.portainer.kubernetes.configuration.owner'];
const hasConfigurationOwner = !!(
row.metadata?.labels?.[configurationOwnerUsernameLabel] ||
row.metadata?.labels?.[appOwnerLabel]
);
return `${name} ${isSystemSecret ? 'system' : ''} ${
!isSystemToken && !hasConfigurationOwner ? 'external' : ''
} ${!row.inUse && !isSystemSecret ? 'unused' : ''}`;
@@ -37,10 +41,10 @@ function Cell({ row }: CellContext<SecretRowData, string>) {
const isSystemToken = name?.includes('default-token-');
const isSystemSecret = isSystemToken || row.original.isSystem;
const hasConfigurationOwner =
!!row.original.metadata?.labels?.[
'io.portainer.kubernetes.configuration.owner'
];
const hasConfigurationOwner = !!(
row.original.metadata?.labels?.[configurationOwnerUsernameLabel] ||
row.original.metadata?.labels?.[appOwnerLabel]
);
return (
<Authorized authorizations="K8sSecretsR" childrenUnauthorized={name}>

View File

@@ -0,0 +1,2 @@
export const configurationOwnerUsernameLabel =
'io.portainer.kubernetes.configuration.owner';

View File

@@ -828,6 +828,7 @@ export function CreateIngressView() {
Paths: preparePaths(rule.IngressName, rule.Hosts),
TLS: prepareTLS(rule.Hosts),
Annotations: prepareAnnotations(rule.Annotations || []),
Labels: rule.Labels,
};
if (isEdit) {

View File

@@ -28,6 +28,7 @@ export interface Rule {
Hosts: Host[];
Annotations?: Annotation[];
IngressType?: string;
Labels?: Record<string, string>;
}
export interface ServicePorts {

View File

@@ -104,6 +104,7 @@ export function prepareRuleFromIngress(
Hosts: prepareRuleHostsFromIngress(ing) || [],
Annotations: ing.Annotations ? getAnnotationsForEdit(ing.Annotations) : [],
IngressType: ing.Type,
Labels: ing.Labels,
};
}

View File

@@ -1,4 +1,5 @@
import { formatDate } from '@/portainer/filters/filters';
import { appOwnerLabel } from '@/react/kubernetes/applications/constants';
import { columnHelper } from './helper';
@@ -13,7 +14,8 @@ export const created = columnHelper.accessor(
cell: ({ row, getValue }) => {
const date = formatDate(getValue());
const owner =
row.original.Labels?.['io.portainer.kubernetes.ingress.owner'];
row.original.Labels?.['io.portainer.kubernetes.ingress.owner'] ||
row.original.Labels?.[appOwnerLabel];
return owner ? `${date} by ${owner}` : date;
},

View File

@@ -12,7 +12,6 @@ export function CreateNamespaceView() {
useUnauthorizedRedirect(
{
authorizations: 'K8sResourcePoolsW',
forceEnvironmentId: environmentId,
adminOnlyCE: !isBE,
},
{

View File

@@ -19,13 +19,13 @@ export function RegistriesSelector({
options = [],
inputId,
}: Props) {
const { isAdmin } = useCurrentUser();
const { isPureAdmin } = useCurrentUser();
return (
<>
{options.length === 0 && (
<p className="text-muted text-xs mb-1 mt-2">
{isAdmin ? (
{isPureAdmin ? (
<span>
No registries available. Head over to the{' '}
<Link to="portainer.registries" target="_blank">

View File

@@ -2,7 +2,7 @@ import { Edit2, Settings } from 'lucide-react';
import { ReactNode } from 'react';
import clsx from 'clsx';
import { useUser } from '@/react/hooks/useUser';
import { useCurrentUser } from '@/react/hooks/useUser';
import {
Environment,
PlatformType,
@@ -15,7 +15,7 @@ import {
import { LinkButton } from '@@/LinkButton';
export function EditButtons({ environment }: { environment: Environment }) {
const { isAdmin } = useUser();
const { isPureAdmin } = useCurrentUser();
const isEdgeAsync = checkEdgeAsync(environment);
@@ -31,7 +31,7 @@ export function EditButtons({ environment }: { environment: Environment }) {
return (
<ButtonsGrid className="ml-3 w-11">
<LinkButton
disabled={!isAdmin}
disabled={!isPureAdmin}
to="portainer.endpoints.endpoint"
params={{ id: environment.Id, redirectTo: 'portainer.home' }}
color="none"
@@ -42,7 +42,7 @@ export function EditButtons({ environment }: { environment: Environment }) {
/>
<LinkButton
disabled={!configRoute || isEdgeAsync || !isAdmin}
disabled={!configRoute || isEdgeAsync || !isPureAdmin}
to={configRoute}
params={{ endpointId: environment.Id }}
color="none"

View File

@@ -18,7 +18,7 @@ import {
} from '@/react/portainer/environments/queries/useEnvironmentList';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { useUser } from '@/react/hooks/useUser';
import { useIsPureAdmin } from '@/react/hooks/useUser';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { environmentStore } from '@/react/hooks/current-environment-store';
@@ -46,7 +46,7 @@ interface Props {
const storageKey = 'home_endpoints';
export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
const { isAdmin } = useUser();
const isPureAdmin = useIsPureAdmin();
const currentEnvStore = useStore(environmentStore);
const [platformTypes, setPlatformTypes] = useHomePageFilter<PlatformType[]>(
@@ -138,7 +138,9 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
return (
<>
{totalAvailable === 0 && <NoEnvironmentsInfoPanel isAdmin={isAdmin} />}
{totalAvailable === 0 && (
<NoEnvironmentsInfoPanel isAdmin={isPureAdmin} />
)}
<TableContainer>
<div className="px-4">
@@ -160,7 +162,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
placeholder="Search by name, group, tag, status, URL..."
data-cy="home-endpointsSearchInput"
/>
{isAdmin && (
{isPureAdmin && (
<Button
onClick={onRefresh}
data-cy="home-refreshEndpointsButton"

View File

@@ -1,6 +1,6 @@
import { FormikErrors } from 'formik';
import { useUser } from '@/react/hooks/useUser';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { SwitchField } from '@@/form-components/SwitchField';
@@ -26,7 +26,13 @@ export function AccessControlForm({
errors,
environmentId,
}: Props) {
const { isAdmin } = useUser();
const isAdminQuery = useIsEdgeAdmin();
if (isAdminQuery.isLoading) {
return null;
}
const { isAdmin } = isAdminQuery;
const accessControlEnabled =
values.ownership !== ResourceControlOwnership.PUBLIC;

View File

@@ -1,11 +1,14 @@
import { useReducer } from 'react';
import { Edit, Eye } from 'lucide-react';
import { useUser } from '@/react/hooks/useUser';
import { Icon } from '@/react/components/Icon';
import { TeamMembership, TeamRole } from '@/react/portainer/users/teams/types';
import { useIsTeamLeader, useUserMembership } from '@/portainer/users/queries';
import {
useIsCurrentUserTeamLeader,
useUserMembership,
} from '@/portainer/users/queries';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { TableContainer, TableTitle } from '@@/datatables';
import { Button } from '@@/buttons';
@@ -34,20 +37,27 @@ export function AccessControlPanel({
onUpdateSuccess,
}: Props) {
const [isEditMode, toggleEditMode] = useReducer((state) => !state, false);
const { user, isAdmin } = useUser();
const isAdminQuery = useIsEdgeAdmin();
const isTeamLeader = useIsCurrentUserTeamLeader();
const isInherited = checkIfInherited();
const restrictions = useRestrictions(resourceControl);
if (isAdminQuery.isLoading || !restrictions) {
return null;
}
const { isPartOfRestrictedUsers, isLeaderOfAnyRestrictedTeams } =
useRestrictions(resourceControl);
restrictions;
const { isAdmin } = isAdminQuery;
const isEditDisabled =
disableOwnershipChange ||
isInherited ||
(!isAdmin && !isPartOfRestrictedUsers && !isLeaderOfAnyRestrictedTeams);
const isTeamLeader = useIsTeamLeader(user) as boolean;
return (
<TableContainer>
<TableTitle label="Access control" icon={Eye} />
@@ -106,10 +116,16 @@ export function AccessControlPanel({
}
function useRestrictions(resourceControl?: ResourceControlViewModel) {
const { user, isAdmin } = useUser();
const { user } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const memberships = useUserMembership(user.Id);
if (isAdminQuery.isLoading) {
return undefined;
}
const { isAdmin } = isAdminQuery;
if (!resourceControl || isAdmin) {
return {
isPartOfRestrictedUsers: false,

View File

@@ -3,7 +3,7 @@ import clsx from 'clsx';
import { useMutation } from 'react-query';
import { object } from 'yup';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { notifySuccess } from '@/portainer/services/notifications';
import { EnvironmentId } from '@/react/portainer/environments/types';
@@ -43,7 +43,8 @@ export function AccessControlPanelForm({
onCancelClick,
onUpdateSuccess,
}: Props) {
const { isAdmin, user } = useCurrentUser();
const { user } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const updateAccess = useMutation(
(variables: AccessControlFormData) =>
@@ -63,6 +64,12 @@ export function AccessControlPanelForm({
}
);
if (isAdminQuery.isLoading) {
return null;
}
const { isAdmin } = isAdminQuery;
const initialValues = {
accessControl: parseAccessControlFormData(
isAdmin,

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { FormikErrors } from 'formik';
import { useUser } from '@/react/hooks/useUser';
import { useCurrentUser } from '@/react/hooks/useUser';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { FormError } from '@@/form-components/FormError';
@@ -30,9 +30,10 @@ export function EditDetails({
formNamespace,
environmentId,
}: Props) {
const { user, isAdmin } = useUser();
const { user, isPureAdmin } = useCurrentUser();
const { users, teams, isLoading } = useLoadState(environmentId);
const { users, teams, isLoading } = useLoadState(environmentId, isAdmin);
const handleChange = useCallback(
(partialValues: Partial<typeof values>) => {
onChange({ ...values, ...partialValues });
@@ -41,7 +42,12 @@ export function EditDetails({
[values, onChange]
);
if (isLoading || !teams || (isAdmin && !users) || !values.authorizedUsers) {
if (
isLoading ||
!teams ||
(isPureAdmin && !users) ||
!values.authorizedUsers
) {
return null;
}
@@ -51,14 +57,14 @@ export function EditDetails({
onChange={handleChangeOwnership}
name={withNamespace('ownership')}
value={values.ownership}
isAdmin={isAdmin}
isAdmin={isPureAdmin}
isPublicVisible={isPublicVisible}
teams={teams}
/>
{values.ownership === ResourceControlOwnership.RESTRICTED && (
<div aria-label="extra-options">
{isAdmin && (
{isPureAdmin && (
<UsersField
name={withNamespace('authorizedUsers')}
users={users || []}
@@ -68,12 +74,12 @@ export function EditDetails({
/>
)}
{(isAdmin || teams.length > 1) && (
{(isPureAdmin || teams.length > 1) && (
<TeamsField
name={withNamespace('authorizedTeams')}
teams={teams}
overrideTooltip={
!isAdmin && teams.length > 1
!isPureAdmin && teams.length > 1
? 'As you are a member of multiple teams, you can select which teams(s) will be able to manage this resource.'
: undefined
}
@@ -111,7 +117,7 @@ export function EditDetails({
// Non admin team leaders/members under only one team can
// automatically grant the resource access to all members
// under the team
if (!isAdmin && teams && teams.length === 1) {
if (!isPureAdmin && teams && teams.length === 1) {
authorizedTeams = teams.map((team) => team.Id);
}
}

View File

@@ -1,15 +1,18 @@
import { useTeams } from '@/react/portainer/users/teams/queries';
import { useUsers } from '@/portainer/users/queries';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
export function useLoadState(environmentId: EnvironmentId, enabled = true) {
export function useLoadState(environmentId: EnvironmentId) {
const isAdminQuery = useIsEdgeAdmin();
const teams = useTeams(false, environmentId);
const users = useUsers(false, environmentId, enabled);
const users = useUsers(false, environmentId, isAdminQuery.isAdmin);
return {
teams: teams.data,
users: users.data,
isLoading: teams.isLoading || users.isLoading,
isAdmin: isAdminQuery.isAdmin,
isLoading: teams.isLoading || users.isLoading || isAdminQuery.isLoading,
};
}

View File

@@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { useMemo, useEffect } from 'react';
import { useCurrentUser } from '@/react/hooks/useUser';
import helm from '@/assets/ico/vendor/helm.svg?c';
import { Link } from '@@/Link';
import { Datatable } from '@@/datatables';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
@@ -40,6 +41,19 @@ export function HelmRepositoryDatatable() {
helmReposQuery.data?.UserRepositories,
]);
useEffect(() => {
// window.location.hash will get everything after the hashbang
// the regex will match the the content after each hash
const timeout = setTimeout(() => {
const regEx = /#!.*#(.*)/;
const match = window.location.hash.match(regEx);
if (match && match[1]) {
document.getElementById(match[1])?.scrollIntoView();
}
}, 1000);
return () => clearTimeout(timeout);
}, []);
return (
<Datatable
getRowId={(row) => String(row.Id)}
@@ -47,8 +61,9 @@ export function HelmRepositoryDatatable() {
description={<HelmDatatableDescription />}
settingsManager={tableState}
columns={columns}
title="Helm Repositories"
title="Helm repositories"
titleIcon={helm}
titleId="helm-repositories"
renderTableActions={(selectedRows) => (
<HelmRepositoryDatatableActions selectedItems={selectedRows} />
)}
@@ -64,8 +79,11 @@ function HelmDatatableDescription() {
<TextTip color="blue" className="mb-3">
Adding a Helm repo here only makes it available in your own user
account&apos;s Portainer UI. Helm charts are pulled down from these repos
(plus the globally-set Helm repo) and shown in the Create from Manifest
screen&apos;s Helm charts list.
(plus the{' '}
<Link to="portainer.settings" params={{ '#': 'kubernetes-settings' }}>
<span>globally-set Helm repo</span>
</Link>
) and shown in the Create from Manifest screen&apos;s Helm charts list.
</TextTip>
);
}

View File

@@ -36,7 +36,7 @@ export function HelmRepositoryDatatableActions({ selectedItems }: Props) {
data-cy="credentials-addButton"
icon={Plus}
>
Add Helm Repository
Add Helm repository
</Button>
</>
);

View File

@@ -17,14 +17,14 @@ test('the button is disabled when all fields are blank and enabled when all fiel
const descriptionField = getByLabelText(/Description/);
const passwordField = getByLabelText(/Current password/);
userEvent.type(passwordField, 'password');
userEvent.type(descriptionField, 'description');
await userEvent.type(passwordField, 'password');
await userEvent.type(descriptionField, 'description');
await waitFor(() => {
expect(button).toBeEnabled();
});
userEvent.clear(descriptionField);
await userEvent.clear(descriptionField);
await waitFor(() => {
expect(button).toBeDisabled();
});

View File

@@ -7,14 +7,14 @@ import { Environment, EnvironmentId } from '../types';
import { environmentQueryKeys } from './query-keys';
export function useEnvironment<T = Environment | null>(
export function useEnvironment<T = Environment>(
environmentId?: EnvironmentId,
select?: (environment: Environment | null) => T,
select?: (environment: Environment) => T,
options?: { autoRefreshRate?: number }
) {
return useQuery(
environmentId ? environmentQueryKeys.item(environmentId) : [],
() => (environmentId ? getEndpoint(environmentId) : null),
environmentQueryKeys.item(environmentId!),
() => getEndpoint(environmentId!),
{
select,
...withError('Failed loading environment'),

View File

@@ -4,6 +4,7 @@ import _ from 'lodash';
import { Wand2 } from 'lucide-react';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { useAdminOnlyRedirect } from '@/react/hooks/useAdminOnlyRedirect';
import { Button } from '@@/buttons';
import { PageHeader } from '@@/PageHeader';
@@ -18,6 +19,8 @@ import {
} from './environment-types';
export function EnvironmentTypeSelectView() {
// move this redirect logic to the router when migrating the router to react
useAdminOnlyRedirect();
const [types, setTypes] = useState<EnvironmentOptionValue[]>([]);
const { trackEvent } = useAnalytics();
const router = useRouter();

View File

@@ -10,6 +10,7 @@ import {
EnvironmentId,
} from '@/react/portainer/environments/types';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import { useAdminOnlyRedirect } from '@/react/hooks/useAdminOnlyRedirect';
import { Stepper } from '@@/Stepper';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
@@ -32,6 +33,8 @@ import styles from './EnvironmentsCreationView.module.css';
import { WizardEndpointsList } from './WizardEndpointsList';
export function EnvironmentCreationView() {
// move this redirect logic to the router when migrating the router to react
useAdminOnlyRedirect();
const {
params: { localEndpointId: localEndpointIdParam },
} = useCurrentStateAndParams();

View File

@@ -1,24 +1,27 @@
import { useField } from 'formik';
import { PropsWithChildren } from 'react';
import { useUser } from '@/react/hooks/useUser';
import { useCurrentUser } from '@/react/hooks/useUser';
import { TagSelector } from '@@/TagSelector';
import { FormSection } from '@@/form-components/FormSection';
import { GroupField } from './GroupsField';
export function MetadataFieldset() {
export function MetadataFieldset({ children }: PropsWithChildren<unknown>) {
const [tagProps, , tagHelpers] = useField('meta.tagIds');
const { isAdmin } = useUser();
const { isPureAdmin } = useCurrentUser();
return (
<FormSection title="Metadata">
{children}
<GroupField />
<TagSelector
value={tagProps.value}
allowCreate={isAdmin}
allowCreate={isPureAdmin}
onChange={(value) => tagHelpers.setValue(value)}
/>
</FormSection>

View File

@@ -4,6 +4,7 @@ import { EnvironmentType } from '@/react/portainer/environments/types';
import { useAnalytics } from '@/react/hooks/useAnalytics';
import DockerIcon from '@/assets/ico/vendor/docker-icon.svg?c';
import Kube from '@/assets/ico/kube.svg?c';
import { useAdminOnlyRedirect } from '@/react/hooks/useAdminOnlyRedirect';
import { PageHeader } from '@@/PageHeader';
import { Widget, WidgetBody, WidgetTitle } from '@@/Widget';
@@ -15,6 +16,8 @@ import { useConnectLocalEnvironment } from './useFetchOrCreateLocalEnvironment';
import styles from './HomeView.module.css';
export function HomeView() {
// move this redirect logic to the router when migrating the router to react
useAdminOnlyRedirect();
const localEnvironmentAdded = useConnectLocalEnvironment();
const { trackEvent } = useAnalytics();
return (

View File

@@ -2,10 +2,7 @@ import { Settings as SettingsIcon } from 'lucide-react';
import { Field, Form, Formik, useFormikContext } from 'formik';
import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInIntervalField';
import {
useSettings,
useUpdateSettingsMutation,
} from '@/react/portainer/settings/queries';
import { useUpdateSettingsMutation } from '@/react/portainer/settings/queries';
import { notifySuccess } from '@/portainer/services/notifications';
import { Widget } from '@@/Widget';
@@ -24,17 +21,13 @@ import { EnableTelemetryField } from './EnableTelemetryField';
export function ApplicationSettingsPanel({
onSuccess,
settings,
}: {
onSuccess(settings: Settings): void;
settings: Settings;
}) {
const settingsQuery = useSettings();
const mutation = useUpdateSettingsMutation();
if (!settingsQuery.data) {
return null;
}
const settings = settingsQuery.data;
const initialValues: Values = {
edgeAgentCheckinInterval: settings.EdgeAgentCheckinInterval,
enableTelemetry: settings.EnableTelemetry,

View File

@@ -17,7 +17,7 @@ export function DeploymentOptionsSection() {
const limitedFeature = isLimitedToBE(FeatureId.ENFORCE_DEPLOYMENT_OPTIONS);
return (
<FormSection title="Deployment Options">
<FormSection title="Deployment options">
<div className="form-group">
<div className="col-sm-12">
<SwitchField

View File

@@ -9,7 +9,7 @@ export function HelmSection() {
const [{ name }, { error }] = useField<string>('helmRepositoryUrl');
return (
<FormSection title="Helm Repository">
<FormSection title="Helm repository">
<div className="mb-2">
<TextTip color="blue">
You can specify the URL to your own Helm repository here. See the{' '}

View File

@@ -8,7 +8,8 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { LoadingButton } from '@@/buttons';
import { Widget } from '@@/Widget';
import { useSettings, useUpdateSettingsMutation } from '../../queries';
import { useUpdateSettingsMutation } from '../../queries';
import { Settings } from '../../types';
import { HelmSection } from './HelmSection';
import { KubeConfigSection } from './KubeConfigSection';
@@ -16,19 +17,14 @@ import { FormValues } from './types';
import { DeploymentOptionsSection } from './DeploymentOptionsSection';
import { validation } from './validation';
export function KubeSettingsPanel() {
const settingsQuery = useSettings();
export function KubeSettingsPanel({ settings }: { settings: Settings }) {
const queryClient = useQueryClient();
const environmentId = useEnvironmentId(false);
const mutation = useUpdateSettingsMutation();
if (!settingsQuery.data) {
return null;
}
const initialValues: FormValues = {
helmRepositoryUrl: settingsQuery.data.HelmRepositoryURL || '',
kubeconfigExpiry: settingsQuery.data.KubeconfigExpiry || '0',
helmRepositoryUrl: settings.HelmRepositoryURL || '',
kubeconfigExpiry: settings.KubeconfigExpiry || '0',
globalDeploymentOptions: {
...{
requireNoteOnApplications: false,
@@ -39,12 +35,12 @@ export function KubeSettingsPanel() {
perEnvOverride: false,
hideStacksFunctionality: false,
},
...settingsQuery.data.GlobalDeploymentOptions,
...settings.GlobalDeploymentOptions,
},
};
return (
<Widget>
<Widget id="kubernetes-settings">
<Widget.Title icon={kubeIcon} title="Kubernetes settings" />
<Widget.Body>
<Formik
@@ -66,7 +62,7 @@ export function KubeSettingsPanel() {
loadingText="Saving"
className="!ml-0"
>
Save Kubernetes Settings
Save Kubernetes settings
</LoadingButton>
</div>
</div>

View File

@@ -121,7 +121,7 @@ function SSLSettingsPanel() {
loadingText={reloadingPage ? 'Reloading' : 'Saving'}
className="!ml-0"
>
Save SSL Settings
Save SSL settings
</LoadingButton>
</div>
</div>

View File

@@ -1,9 +1,11 @@
import { useEffect } from 'react';
import angular from 'angular';
import { StateManager } from '@/portainer/services/types';
import { PageHeader } from '@@/PageHeader';
import { useSettings } from '../queries';
import { Settings } from '../types';
import { isBE } from '../../feature-flags/feature-flags.service';
@@ -16,14 +18,33 @@ import { SSLSettingsPanelWrapper } from './SSLSettingsPanel/SSLSettingsPanel';
import { ExperimentalFeatures } from './ExperimentalFeatures';
export function SettingsView() {
const settingsQuery = useSettings();
useEffect(() => {
if (settingsQuery.data) {
const regEx = /#!.*#(.*)/;
const match = window.location.hash.match(regEx);
if (match && match[1]) {
document.getElementById(match[1])?.scrollIntoView();
}
}
}, [settingsQuery.data]);
return (
<>
<PageHeader title="Settings" breadcrumbs="Settings" reload />
<div className="mx-4 space-y-4">
<ApplicationSettingsPanel onSuccess={handleSuccess} />
{settingsQuery.data && (
<>
<ApplicationSettingsPanel
onSuccess={handleSuccess}
settings={settingsQuery.data}
/>
<KubeSettingsPanel />
<KubeSettingsPanel settings={settingsQuery.data} />
</>
)}
<HelmCertPanel />

View File

@@ -0,0 +1,102 @@
import userEvent from '@testing-library/user-event';
import { PropsWithChildren } from 'react';
import { render } from '@/react-tools/test-utils';
import { AppTemplatesListItem } from './AppTemplatesListItem';
import { TemplateViewModel } from './view-model';
import { TemplateType } from './types';
test('should render AppTemplatesListItem component', () => {
const template: TemplateViewModel = {
Title: 'Test Template',
// provide necessary properties for the template object
} as TemplateViewModel;
const onSelect = vi.fn();
const isSelected = false;
const { getByText } = render(
<AppTemplatesListItem
template={template}
onSelect={onSelect}
isSelected={isSelected}
/>
);
expect(getByText(template.Title, { exact: false })).toBeInTheDocument();
});
const copyAsCustomTestCases = [
{
type: TemplateType.Container,
expected: false,
},
{
type: TemplateType.ComposeStack,
expected: true,
},
{
type: TemplateType.SwarmStack,
expected: true,
},
];
// TODO - remove after fixing workaround for UISref
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
UISref: ({ children }: PropsWithChildren<unknown>) => children, // Mocking UISref to render its children directly
}));
copyAsCustomTestCases.forEach(({ type, expected }) => {
test(`copy as custom button should ${
expected ? '' : 'not '
}be rendered for type ${type}`, () => {
const onSelect = vi.fn();
const isSelected = false;
const { queryByText, unmount } = render(
<AppTemplatesListItem
template={
{
Type: type,
} as TemplateViewModel
}
onSelect={onSelect}
isSelected={isSelected}
/>
);
if (expected) {
expect(queryByText('Copy as Custom')).toBeVisible();
} else {
expect(queryByText('Copy as Custom')).toBeNull();
}
unmount();
});
});
test('should call onSelect when clicked', async () => {
const user = userEvent.setup();
const template: TemplateViewModel = {
Title: 'Test Template',
// provide necessary properties for the template object
} as TemplateViewModel;
const onSelect = vi.fn();
const isSelected = false;
const { getByLabelText } = render(
<AppTemplatesListItem
template={template}
onSelect={onSelect}
isSelected={isSelected}
/>
);
const button = getByLabelText(template.Title);
await user.click(button);
expect(onSelect).toHaveBeenCalledWith(template);
});

View File

@@ -45,6 +45,7 @@ export function TemplateItem({
as={linkParams ? Link : undefined}
to={linkParams?.to}
params={linkParams?.params}
aria-label={template.Title}
>
<div className="vertical-center min-w-[56px] justify-center">
<FallbackImage

View File

@@ -1,7 +1,7 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { StackType } from '@/react/common/stacks/types';
import { Platform } from '../../types';
@@ -19,11 +19,13 @@ export function useInitialValues({
isEdge?: boolean;
buildMethods: Array<Method>;
}): FormValues | undefined {
const { user, isAdmin } = useCurrentUser();
const { user } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const { appTemplateId, type = defaultType } = useAppTemplateParams();
const fileContentQuery = useFetchTemplateFile(appTemplateId);
if (fileContentQuery.isLoading) {
if (fileContentQuery.isLoading || isAdminQuery.isLoading) {
return undefined;
}
@@ -51,7 +53,7 @@ export function useInitialValues({
},
AccessControl: isEdge
? undefined
: parseAccessControlFormData(isAdmin, user.Id),
: parseAccessControlFormData(isAdminQuery.isAdmin, user.Id),
EdgeSettings: isEdge ? getDefaultEdgeTemplateSettings() : undefined,
};
}

View File

@@ -46,7 +46,7 @@ export function EditForm({
templateFile: fileContentQuery.data,
});
if (fileContentQuery.isLoading) {
if (fileContentQuery.isLoading || !initialValues) {
return null;
}

View File

@@ -1,5 +1,5 @@
import { parseAccessControlFormData } from '@/react/portainer/access-control/utils';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { toGitFormModel } from '@/react/portainer/gitops/types';
import { ResourceControlViewModel } from '@/react/portainer/access-control/models/ResourceControlViewModel';
@@ -15,8 +15,14 @@ export function useInitialValues({
template: CustomTemplate;
templateFile: string | undefined;
isEdge: boolean;
}): FormValues {
const { user, isAdmin } = useCurrentUser();
}): FormValues | undefined {
const { user } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
if (isAdminQuery.isLoading) {
return undefined;
}
return {
Title: template.Title,
@@ -31,7 +37,7 @@ export function useInitialValues({
AccessControl:
!isEdge && template.ResourceControl
? parseAccessControlFormData(
isAdmin,
isAdminQuery.isAdmin,
user.Id,
new ResourceControlViewModel(template.ResourceControl)
)

View File

@@ -1,6 +1,6 @@
import { Edit, Trash2 } from 'lucide-react';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { StackType } from '@/react/common/stacks/types';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
@@ -22,8 +22,15 @@ export function CustomTemplatesListItem({
isSelected: boolean;
linkParams?: { to: string; params: object };
}) {
const { isAdmin, user } = useCurrentUser();
const isEditAllowed = isAdmin || template.CreatedByUserId === user.Id;
const { user } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
if (isAdminQuery.isLoading) {
return null;
}
const isEditAllowed =
isAdminQuery.isAdmin || template.CreatedByUserId === user.Id;
return (
<TemplateItem

View File

@@ -1,7 +1,7 @@
import { useRouter } from '@uirouter/react';
import { useUsers } from '@/portainer/users/queries';
import { useUser } from '@/react/hooks/useUser';
import { useIsPureAdmin } from '@/react/hooks/useUser';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { TextTip } from '@@/Tip/TextTip';
@@ -16,7 +16,7 @@ import { useTeamIdParam } from './useTeamIdParam';
export function ItemView() {
const teamId = useTeamIdParam();
const { isAdmin } = useUser();
const isPureAdmin = useIsPureAdmin();
const router = useRouter();
const teamQuery = useTeam(teamId, () =>
router.stateService.go('portainer.teams')
@@ -45,7 +45,7 @@ export function ItemView() {
<Details
team={team}
memberships={membershipsQuery.data}
isAdmin={isAdmin}
isAdmin={isPureAdmin}
/>
)}

View File

@@ -3,7 +3,7 @@ import { Users, UserX } from 'lucide-react';
import { User, UserId } from '@/portainer/users/types';
import { TeamId, TeamRole } from '@/react/portainer/users/teams/types';
import { useUser } from '@/react/hooks/useUser';
import { useIsPureAdmin } from '@/react/hooks/useUser';
import { notifySuccess } from '@/portainer/services/notifications';
import {
useRemoveMemberMutation,
@@ -37,8 +37,7 @@ export function TeamMembersList({ users, roles, disabled, teamId }: Props) {
{ id: string; desc: boolean } | undefined
>({ id: 'name', desc: false });
const { isAdmin } = useUser();
const isPureAdmin = useIsPureAdmin();
const rowContext = useMemo<RowContext>(
() => ({
getRole(userId: UserId) {
@@ -58,7 +57,7 @@ export function TeamMembersList({ users, roles, disabled, teamId }: Props) {
titleIcon={Users}
title="Team members"
renderTableActions={() =>
isAdmin && (
isPureAdmin && (
<Button
onClick={() => handleRemoveMembers(users.map((user) => user.Id))}
disabled={disabled || users.length === 0}

View File

@@ -2,7 +2,7 @@ import { User as UserIcon, UserPlus, UserX } from 'lucide-react';
import { CellContext } from '@tanstack/react-table';
import { User } from '@/portainer/users/types';
import { useUser as useCurrentUser } from '@/react/hooks/useUser';
import { useCurrentUser } from '@/react/hooks/useUser';
import { TeamRole } from '@/react/portainer/users/teams/types';
import { notifySuccess } from '@/portainer/services/notifications';
import {
@@ -23,7 +23,7 @@ export const teamRole = columnHelper.accessor('Id', {
cell: RoleCell,
});
export function RoleCell({
function RoleCell({
row: { original: user },
getValue,
}: CellContext<User, User['Id']>) {
@@ -38,12 +38,16 @@ export function RoleCell({
const role = getRole(id);
const { isAdmin } = useCurrentUser();
const { isPureAdmin } = useCurrentUser();
const Cell = role === TeamRole.Leader ? LeaderCell : MemberCell;
return (
<Cell isAdmin={isAdmin} onClick={handleUpdateRole} disabled={disabled} />
<Cell
isAdmin={isPureAdmin}
onClick={handleUpdateRole}
disabled={disabled}
/>
);
function handleUpdateRole(role: TeamRole, onSuccessMessage: string) {

Some files were not shown because too many files have changed in this diff Show More