Repository: replicatedhq/kots
Pull request #2755 (2022-04-26 23:08:13)

feature: add change password modal to footer
<!--  Thanks for sending a pull request!  Here are some tips for you:

1. If this is your first time, please read our contributor guidelines here:
https://github.com/replicatedhq/kots/blob/main/CONTRIBUTING.md.
2. Ensure you have added appropriate tests for your PR. For more information read here:
https://github.com/replicatedhq/kots/blob/main/CONTRIBUTING.md#testing
3. If the PR is unfinished, please mark it as a draft.
-->

#### What type of PR is this?

<!--
Please choose from one of the following:
type::bug
type::docs
type::feature
type::security
type::chore
type::tests
-->

type::feature

#### What this PR does / why we need it:

This PR adds a 'Change Password' button to the footer in KOTS, which opens a modal so the user can change the login password. In this modal, the user must enter their current password as well as their new password (and confirm it). There is a mix of client and server side validation that runs, and if an error occurs it will show the error message next to the Change Password button. Each input field also has a show/hide toggle so the user can see what they are typing.

#### Which issue(s) this PR fixes:
<!--
*Automatically closes linked issue when PR is merged.
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes [SC-44273](https://app.shortcut.com/replicated/story/44273/method-to-change-password-in-kots-admin-console-ui)

#### Special notes for your reviewer:

None

#### Does this PR introduce a user-facing change?
<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
-->
```release-note
Adds the ability to change your password. Adds a 'Change Password' button to the page footer.
```

#### Does this PR require documentation?
<!--
If no, just write "NONE" below.
If yes, link to the related https://github.com/replicatedhq/kots.io documentation PR:
-->

Yes

#### Demo

https://www.loom.com/share/fc10d8422cf84603ad380031fa695abb


Patch:
diff --git a/.github/workflows/codespaces.yaml b/.github/workflows/codespaces.yaml
index ed6557118..536e7bf22 100644
--- a/.github/workflows/codespaces.yaml
+++ b/.github/workflows/codespaces.yaml
@@ -2,18 +2,18 @@ name: prebuild codespace
 on:
   push:
     branches:
-    - main
-    - codespaces-sandbox
-  workflow_dispatch: 
+      #    - main
+      - codespaces-sandbox
+  workflow_dispatch:
 
 jobs:
   createPrebuild:
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v2
-    - uses: github/codespaces-precache@v1-stable
-      with:
-        regions: WestUs2 EastUs SouthEastAsia
-        sku_name: premiumLinux
-      env:
-        GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
+      - uses: actions/checkout@v2
+      - uses: github/codespaces-precache@v1-stable
+        with:
+          regions: WestUs2 EastUs SouthEastAsia
+          sku_name: premiumLinux
+        env:
+          GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
diff --git a/pkg/appstate/ingress.go b/pkg/appstate/ingress.go
index ddcbf0017..bc4673aeb 100644
--- a/pkg/appstate/ingress.go
+++ b/pkg/appstate/ingress.go
@@ -5,10 +5,9 @@ import (
 	"time"
 
 	"github.com/replicatedhq/kots/pkg/appstate/types"
-	extensions "k8s.io/api/extensions/v1beta1"
+	networkingv1 "k8s.io/api/networking/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
-	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/apimachinery/pkg/watch"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/cache"
@@ -28,15 +27,15 @@ func runIngressController(
 ) {
 	listwatch := &cache.ListWatch{
 		ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
-			return clientset.ExtensionsV1beta1().Ingresses(targetNamespace).List(context.TODO(), options)
+			return clientset.NetworkingV1().Ingresses(targetNamespace).List(context.TODO(), options)
 		},
 		WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
-			return clientset.ExtensionsV1beta1().Ingresses(targetNamespace).Watch(context.TODO(), options)
+			return clientset.NetworkingV1().Ingresses(targetNamespace).Watch(context.TODO(), options)
 		},
 	}
 	informer := cache.NewSharedInformer(
 		listwatch,
-		&extensions.Ingress{},
+		&networkingv1.Ingress{},
 		// NOTE: ingresses rely on endpoint and service status as well so unless we add
 		// additional informers, we have to resync more frequently.
 		10*time.Second,
@@ -90,12 +89,12 @@ func (h *ingressEventHandler) ObjectDeleted(obj interface{}) {
 	h.resourceStateCh <- makeIngressResourceState(r, types.StateMissing)
 }
 
-func (h *ingressEventHandler) cast(obj interface{}) *extensions.Ingress {
-	r, _ := obj.(*extensions.Ingress)
+func (h *ingressEventHandler) cast(obj interface{}) *networkingv1.Ingress {
+	r, _ := obj.(*networkingv1.Ingress)
 	return r
 }
 
-func (h *ingressEventHandler) getInformer(r *extensions.Ingress) (types.StatusInformer, bool) {
+func (h *ingressEventHandler) getInformer(r *networkingv1.Ingress) (types.StatusInformer, bool) {
 	if r != nil {
 		for _, informer := range h.informers {
 			if r.Namespace == informer.Namespace && r.Name == informer.Name {
@@ -106,7 +105,7 @@ func (h *ingressEventHandler) getInformer(r *extensions.Ingress) (types.StatusIn
 	return types.StatusInformer{}, false
 }
 
-func makeIngressResourceState(r *extensions.Ingress, state types.State) types.ResourceState {
+func makeIngressResourceState(r *networkingv1.Ingress, state types.State) types.ResourceState {
 	return types.ResourceState{
 		Kind:      IngressResourceKind,
 		Name:      r.Name,
@@ -115,17 +114,21 @@ func makeIngressResourceState(r *extensions.Ingress, state types.State) types.Re
 	}
 }
 
-func calculateIngressState(clientset kubernetes.Interface, r *extensions.Ingress) types.State {
+func calculateIngressState(clientset kubernetes.Interface, r *networkingv1.Ingress) types.State {
 	var states []types.State
 	// https://github.com/kubernetes/kubectl/blob/6b77b0790ab40d2a692ad80e9e4c962e784bb9b8/pkg/describe/versioned/describe.go#L2367
-	backend := r.Spec.Backend
+	backend := r.Spec.DefaultBackend
 	ns := r.Namespace
 	if backend == nil {
 		// Ingresses that don't specify a default backend inherit the
 		// default backend in the kube-system namespace.
-		backend = &extensions.IngressBackend{
-			ServiceName: "default-http-backend",
-			ServicePort: intstr.IntOrString{Type: intstr.Int, IntVal: 80},
+		backend = &networkingv1.IngressBackend{
+			Service: &networkingv1.IngressServiceBackend{
+				Name: "default-http-backend",
+				Port: networkingv1.ServiceBackendPort{
+					Number: 80,
+				},
+			},
 		}
 		ns = metav1.NamespaceSystem
 	}
@@ -140,15 +143,18 @@ func calculateIngressState(clientset kubernetes.Interface, r *extensions.Ingress
 	return types.MinState(states...)
 }
 
-func ingressGetStateFromBackend(clientset kubernetes.Interface, namespace string, backend extensions.IngressBackend) (minState types.State) {
-	service, _ := clientset.CoreV1().Services(namespace).Get(context.TODO(), backend.ServiceName, metav1.GetOptions{})
+func ingressGetStateFromBackend(clientset kubernetes.Interface, namespace string, backend networkingv1.IngressBackend) (minState types.State) {
+	if backend.Service == nil {
+		return types.StateUnavailable
+	}
+	service, _ := clientset.CoreV1().Services(namespace).Get(context.TODO(), backend.Service.Name, metav1.GetOptions{})
 	if service == nil {
 		return types.StateUnavailable
 	}
 	return serviceGetStateFromEndpoints(clientset, service)
 }
 
-func ingressGetStateFromExternalIP(ing *extensions.Ingress) types.State {
+func ingressGetStateFromExternalIP(ing *networkingv1.Ingress) types.State {
 	lbIps := loadBalancerStatusIPs(ing.Status.LoadBalancer)
 	if len(lbIps) > 0 {
 		return types.StateReady
diff --git a/pkg/base/replicated.go b/pkg/base/replicated.go
index 515e1dc19..20896b71d 100644
--- a/pkg/base/replicated.go
+++ b/pkg/base/replicated.go
@@ -295,7 +295,7 @@ func renderReplicatedHelmBase(u *upstreamtypes.Upstream, renderOptions *RenderOp
 func upstreamFileToBaseFile(upstreamFile upstreamtypes.UpstreamFile, builder template.Builder, log *logger.CLILogger) (BaseFile, error) {
 	rendered, err := builder.RenderTemplate(upstreamFile.Path, string(upstreamFile.Content))
 	if err != nil {
-		log.Error(errors.Errorf("Failed to render file %s. Contents are %s", upstreamFile.Path, upstreamFile.Content))
+		log.Error(errors.Wrapf(err, "failed to render file %s. Contents are %s", upstreamFile.Path, upstreamFile.Content))
 		return BaseFile{}, errors.Wrap(err, "failed to render file template")
 	}
 
@@ -319,6 +319,7 @@ func findAllKotsHelmCharts(upstreamFiles []upstreamtypes.UpstreamFile, builder t
 
 		helmChart, err := parseHelmChart(baseFile.Content)
 		if err != nil {
+			fmt.Printf("Failed HelmChart contents:\n%s\n", string(baseFile.Content))
 			return nil, errors.Wrapf(err, "failed to parse rendered HelmChart %s", baseFile.Path)
 		}
 
diff --git a/pkg/identity/deploy/deploy.go b/pkg/identity/deploy/deploy.go
index 5752bddc6..7b7d286e6 100644
--- a/pkg/identity/deploy/deploy.go
+++ b/pkg/identity/deploy/deploy.go
@@ -17,7 +17,7 @@ import (
 	"github.com/replicatedhq/kots/pkg/template"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
-	extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
+	networkingv1 "k8s.io/api/networking/v1"
 	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -564,7 +564,7 @@ func ensureIngress(ctx context.Context, clientset kubernetes.Interface, namespac
 	return ingress.EnsureIngress(ctx, clientset, namespace, dexIngress)
 }
 
-func ingressResource(options Options) *extensionsv1beta1.Ingress {
+func ingressResource(options Options) *networkingv1.Ingress {
 	ingressSpec := options.IdentityConfigSpec.IngressConfig
 	if ingressSpec.Ingress == nil {
 		return nil
@@ -573,7 +573,7 @@ func ingressResource(options Options) *extensionsv1beta1.Ingress {
 		*ingressSpec.Ingress,
 		prefixName(options.NamePrefix, "dex"),
 		types.ServiceName(options.NamePrefix),
-		int(types.ServicePort()),
+		types.ServicePort(),
 		AdditionalLabels(options.NamePrefix, options.AdditionalLabels),
 	)
 }
diff --git a/pkg/identity/deploy/undeploy.go b/pkg/identity/deploy/undeploy.go
index c2427368e..c475ce4d3 100644
--- a/pkg/identity/deploy/undeploy.go
+++ b/pkg/identity/deploy/undeploy.go
@@ -29,7 +29,7 @@ func Undeploy(ctx context.Context, clientset kubernetes.Interface, namespace, na
 }
 
 func deleteIngress(ctx context.Context, clientset kubernetes.Interface, namespace, namePrefix string) error {
-	err := clientset.ExtensionsV1beta1().Ingresses(namespace).Delete(ctx, prefixName(namePrefix, "dex"), metav1.DeleteOptions{})
+	err := clientset.NetworkingV1().Ingresses(namespace).Delete(ctx, prefixName(namePrefix, "dex"), metav1.DeleteOptions{})
 	if kuberneteserrors.IsNotFound(err) {
 		return nil
 	}
diff --git a/pkg/ingress/deploy.go b/pkg/ingress/deploy.go
index 414d4a795..a2231ab53 100644
--- a/pkg/ingress/deploy.go
+++ b/pkg/ingress/deploy.go
@@ -4,20 +4,20 @@ import (
 	"context"
 
 	"github.com/pkg/errors"
-	extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
+	networkingv1 "k8s.io/api/networking/v1"
 	kuberneteserrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/client-go/kubernetes"
 )
 
-func EnsureIngress(ctx context.Context, clientset kubernetes.Interface, namespace string, ingress *extensionsv1beta1.Ingress) error {
-	existing, err := clientset.ExtensionsV1beta1().Ingresses(namespace).Get(ctx, ingress.Name, metav1.GetOptions{})
+func EnsureIngress(ctx context.Context, clientset kubernetes.Interface, namespace string, ingress *networkingv1.Ingress) error {
+	existing, err := clientset.NetworkingV1().Ingresses(namespace).Get(ctx, ingress.Name, metav1.GetOptions{})
 	if err != nil {
 		if !kuberneteserrors.IsNotFound(err) {
 			return errors.Wrap(err, "failed to get existing ingress")
 		}
 
-		_, err = clientset.ExtensionsV1beta1().Ingresses(namespace).Create(ctx, ingress, metav1.CreateOptions{})
+		_, err = clientset.NetworkingV1().Ingresses(namespace).Create(ctx, ingress, metav1.CreateOptions{})
 		if err != nil {
 			return errors.Wrap(err, "failed to create ingress")
 		}
@@ -27,7 +27,7 @@ func EnsureIngress(ctx context.Context, clientset kubernetes.Interface, namespac
 
 	existing = updateIngress(existing, ingress)
 
-	_, err = clientset.ExtensionsV1beta1().Ingresses(namespace).Update(ctx, existing, metav1.UpdateOptions{})
+	_, err = clientset.NetworkingV1().Ingresses(namespace).Update(ctx, existing, metav1.UpdateOptions{})
 	if err != nil {
 		return errors.Wrap(err, "failed to update ingress")
 	}
@@ -35,7 +35,7 @@ func EnsureIngress(ctx context.Context, clientset kubernetes.Interface, namespac
 	return nil
 }
 
-func updateIngress(existing, desiredIngress *extensionsv1beta1.Ingress) *extensionsv1beta1.Ingress {
+func updateIngress(existing, desiredIngress *networkingv1.Ingress) *networkingv1.Ingress {
 	existing.Annotations = desiredIngress.Annotations
 	existing.Spec.Rules = desiredIngress.Spec.Rules
 	existing.Spec.TLS = desiredIngress.Spec.TLS
diff --git a/pkg/ingress/ingress_config.go b/pkg/ingress/ingress_config.go
index 2b8897b57..67e11bc3e 100644
--- a/pkg/ingress/ingress_config.go
+++ b/pkg/ingress/ingress_config.go
@@ -3,16 +3,14 @@ package ingress
 import (
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	kotsadmtypes "github.com/replicatedhq/kots/pkg/kotsadm/types"
-	extensions "k8s.io/api/extensions/v1beta1"
-	extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
+	networkingv1 "k8s.io/api/networking/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
-	"k8s.io/apimachinery/pkg/util/intstr"
 )
 
-func IngressFromConfig(ingressConfig kotsv1beta1.IngressResourceConfig, name string, serviceName string, servicePort int, additionalLabels map[string]string) *extensionsv1beta1.Ingress {
-	ingressTLS := []extensions.IngressTLS{}
+func IngressFromConfig(ingressConfig kotsv1beta1.IngressResourceConfig, name string, serviceName string, servicePort int32, additionalLabels map[string]string) *networkingv1.Ingress {
+	ingressTLS := []networkingv1.IngressTLS{}
 	if ingressConfig.TLSSecretName != "" {
-		tls := extensions.IngressTLS{
+		tls := networkingv1.IngressTLS{
 			SecretName: ingressConfig.TLSSecretName,
 		}
 		if ingressConfig.Host != "" {
@@ -28,9 +26,11 @@ func IngressFromConfig(ingressConfig kotsv1beta1.IngressResourceConfig, name str
 		annotations[k] = v
 	}
 
-	return &extensionsv1beta1.Ingress{
+	pathType := networkingv1.PathTypeImplementationSpecific
+
+	return &networkingv1.Ingress{
 		TypeMeta: metav1.TypeMeta{
-			APIVersion: "extensions/v1beta1",
+			APIVersion: "networking.k8s.io/v1",
 			Kind:       "Ingress",
 		},
 		ObjectMeta: metav1.ObjectMeta{
@@ -38,18 +38,23 @@ func IngressFromConfig(ingressConfig kotsv1beta1.IngressResourceConfig, name str
 			Labels:      kotsadmtypes.GetKotsadmLabels(additionalLabels),
 			Annotations: annotations,
 		},
-		Spec: extensionsv1beta1.IngressSpec{
-			Rules: []extensionsv1beta1.IngressRule{
+		Spec: networkingv1.IngressSpec{
+			Rules: []networkingv1.IngressRule{
 				{
 					Host: ingressConfig.Host,
-					IngressRuleValue: extensionsv1beta1.IngressRuleValue{
-						HTTP: &extensionsv1beta1.HTTPIngressRuleValue{
-							Paths: []extensionsv1beta1.HTTPIngressPath{
+					IngressRuleValue: networkingv1.IngressRuleValue{
+						HTTP: &networkingv1.HTTPIngressRuleValue{
+							Paths: []networkingv1.HTTPIngressPath{
 								{
-									Path: ingressConfig.Path,
-									Backend: extensionsv1beta1.IngressBackend{
-										ServiceName: serviceName,
-										ServicePort: intstr.FromInt(servicePort),
+									Path:     ingressConfig.Path,
+									PathType: &pathType,
+									Backend: networkingv1.IngressBackend{
+										Service: &networkingv1.IngressServiceBackend{
+											Name: serviceName,
+											Port: networkingv1.ServiceBackendPort{
+												Number: servicePort,
+											},
+										},
 									},
 								},
 							},
diff --git a/pkg/kotsadm/ingress.go b/pkg/kotsadm/ingress.go
index dccda753f..d9e27365c 100644
--- a/pkg/kotsadm/ingress.go
+++ b/pkg/kotsadm/ingress.go
@@ -21,7 +21,7 @@ func EnsureIngress(ctx context.Context, namespace string, clientset *kubernetes.
 }
 
 func DeleteIngress(ctx context.Context, namespace string, clientset *kubernetes.Clientset) error {
-	err := clientset.ExtensionsV1beta1().Ingresses(namespace).Delete(ctx, "kotsadm", metav1.DeleteOptions{})
+	err := clientset.NetworkingV1().Ingresses(namespace).Delete(ctx, "kotsadm", metav1.DeleteOptions{})
 	if kuberneteserrors.IsNotFound(err) {
 		err = nil
 	}
diff --git a/pkg/kotsadm/objects/kotsadm_objects.go b/pkg/kotsadm/objects/kotsadm_objects.go
index eef635c7d..a9978010b 100644
--- a/pkg/kotsadm/objects/kotsadm_objects.go
+++ b/pkg/kotsadm/objects/kotsadm_objects.go
@@ -2,6 +2,7 @@ package kotsadm
 
 import (
 	"fmt"
+
 	"github.com/pkg/errors"
 	kotsv1beta1 "github.com/replicatedhq/kots/kotskinds/apis/kots/v1beta1"
 	"github.com/replicatedhq/kots/pkg/ingress"
@@ -10,7 +11,7 @@ import (
 	kotsadmversion "github.com/replicatedhq/kots/pkg/kotsadm/version"
 	appsv1 "k8s.io/api/apps/v1"
 	corev1 "k8s.io/api/core/v1"
-	extensionsv1beta1 "k8s.io/api/extensions/v1beta1"
+	networkingv1 "k8s.io/api/networking/v1"
 	rbacv1 "k8s.io/api/rbac/v1"
 	"k8s.io/apimachinery/pkg/api/resource"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -1250,6 +1251,6 @@ func KotsadmService(namespace string, nodePort int32) *corev1.Service {
 	return service
 }
 
-func KotsadmIngress(namespace string, ingressConfig kotsv1beta1.IngressResourceConfig) *extensionsv1beta1.Ingress {
+func KotsadmIngress(namespace string, ingressConfig kotsv1beta1.IngressResourceConfig) *networkingv1.Ingress {
 	return ingress.IngressFromConfig(ingressConfig, "kotsadm", "kotsadm", 3000, nil)
 }
diff --git a/pkg/template/static_context.go b/pkg/template/static_context.go
index db5ddc9ad..7a404bdd3 100644
--- a/pkg/template/static_context.go
+++ b/pkg/template/static_context.go
@@ -31,7 +31,7 @@ import (
 	"github.com/replicatedhq/kots/pkg/k8sutil"
 	"github.com/replicatedhq/kots/pkg/util"
 	analyze "github.com/replicatedhq/troubleshoot/pkg/analyze"
-	"gopkg.in/yaml.v2"
+	"gopkg.in/yaml.v3"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	discovery "k8s.io/client-go/discovery"
@@ -96,6 +96,8 @@ func (ctx StaticCtx) FuncMap() template.FuncMap {
 	funcMap["HTTPProxy"] = ctx.httpProxy
 	funcMap["NoProxy"] = ctx.noProxy
 
+	funcMap["YamlEscape"] = ctx.yamlEscape
+
 	return funcMap
 }
 
@@ -588,3 +590,20 @@ func (ctx StaticCtx) noProxy() string {
 func (ctx StaticCtx) kotsVersion() string {
 	return strings.TrimPrefix(buildversion.Version(), "v")
 }
+
+func (ctx StaticCtx) yamlEscape(plain string) string {
+	marshalled, err := yaml.Marshal(plain)
+	if err != nil {
+		return ""
+	}
+
+	// it is possible for this function to produce multiline yaml, so we indent it a bunch for safety
+	indented := indent(20, string(marshalled))
+	return indented
+}
+
+// copied from sprig
+func indent(spaces int, v string) string {
+	pad := strings.Repeat(" ", spaces)
+	return pad + strings.Replace(v, "\n", "\n"+pad, -1)
+}
diff --git a/web/src/components/modals/ChangePasswordModal/ChangePasswordForm.jsx b/web/src/components/modals/ChangePasswordModal/ChangePasswordForm.jsx
new file mode 100644
index 000000000..dc9b99e0a
--- /dev/null
+++ b/web/src/components/modals/ChangePasswordModal/ChangePasswordForm.jsx
@@ -0,0 +1,126 @@
+import React from "react";
+import InputField from "../../shared/forms/InputField";
+import { Utilities } from "@src/utilities/utilities";
+
+import "@src/scss/components/modals/ChangePasswordModal/ChangePasswordForm.scss";
+
+const ChangePasswordForm = ({ handleClose, handleSetPasswordChangeSuccessful }) => {
+  const [err, setErr] = React.useState({
+    status: false,
+    message: ""
+  });
+  const [loading, setLoading] = React.useState(false);
+  const [inputs, setInputs] = React.useState({
+    currentPassword: "",
+    newPassword: "",
+    confirmPassword: ""
+  });
+
+  const updateFormStatus = (loadingStatus, errStatus, message) => {
+    setErr({
+      status: errStatus,
+      message: message
+    });
+    setLoading(loadingStatus);
+  }
+
+  const validatePassword = () => {
+    if (!inputs.currentPassword || inputs.currentPassword.length === "0") {
+      updateFormStatus(false, true, "Current password is required.");
+      return false;
+    }
+    if (!inputs.newPassword || !inputs.confirmPassword || inputs.newPassword.length === "0" || inputs.confirmPassword.length === "0") {
+      updateFormStatus(false, true, "Please ensure you've filled out both new password fields.");
+      return false;
+    }
+    if (inputs.newPassword !== inputs.confirmPassword) {
+      updateFormStatus(false, true, "Passwords do not match.");
+      return false;
+    }
+    return true;
+  }
+
+  const handleSubmit = (e) => {
+    e.preventDefault();
+    if (validatePassword()) {
+      updateFormStatus(true, false, "");
+
+      fetch(`${process.env.API_ENDPOINT}/password/change`, {
+        headers: {
+          "Authorization": Utilities.getToken(),
+          "Content-Type": "application/json",
+        },
+        method: "PUT",
+        body: JSON.stringify({
+          current_password: inputs.currentPassword,
+          new_password: inputs.newPassword,
+        })
+      })
+      .then(async (res) => {
+        if (res.status >= 400) {
+          let body = await res.json();
+          let msg = body.error;
+          if (!msg) {
+            msg = res.status === 401 ? "User unauthorized. Please try again" : "There was an error changing your password. Please try again.";
+          }
+          updateFormStatus(false, true, msg);
+          handleSetPasswordChangeSuccessful(false);
+          return;
+        }
+        handleSetPasswordChangeSuccessful(true);
+      })
+      .catch((err) => {
+        console.log("Login failed:", err);
+        updateFormStatus(false, true, "There was an error changing your password. Please try again.");
+        handleSetPasswordChangeSuccessful(false);
+      });
+    }
+  }
+
+  return (
+    <form
+      className="change-password-form flex-column"
+      onSubmit={(e) => handleSubmit(e)}
+    >
+      <InputField
+        label="Current password"
+        id="current-password"
+        placeholder="current password"
+        type="password"
+        value={inputs.currentPassword}
+        onChange={e => setInputs({ ...inputs, currentPassword: e.target.value })}
+      />
+      <InputField
+        label="New password"
+        id="new-password"
+        placeholder="new password"
+        type="password"
+        value={inputs.newPassword}
+        onChange={e => setInputs({ ...inputs, newPassword: e.target.value })}
+      />
+      <InputField
+        label="Confirm new password"
+        id="new-password-confirm"
+        placeholder="confirm new password"
+        value={inputs.confirmPassword}
+        type="password"
+        onChange={e => setInputs({ ...inputs, confirmPassword: e.target.value })}
+      />
+      <div className="flex change-password-submit-section">
+        <button type="reset" className="btn secondary blue" onClick={handleClose}>
+          Cancel
+        </button>
+        <button className="btn primary" type="submit">
+          Change Password
+        </button>
+        {err.status &&
+          <p className="change-password-error">
+            {err.message}
+          </p>
+        }
+      </div>
+    </form>
+  )
+}
+
+export default ChangePasswordForm;
diff --git a/web/src/components/modals/ChangePasswordModal/ChangePasswordModal.jsx b/web/src/components/modals/ChangePasswordModal/ChangePasswordModal.jsx
new file mode 100644
index 000000000..639e75a91
--- /dev/null
+++ b/web/src/components/modals/ChangePasswordModal/ChangePasswordModal.jsx
@@ -0,0 +1,106 @@
+import React from "react";
+import Modal from "react-modal";
+import Warning from "../../shared/Warning";
+import ChangePasswordForm from "./ChangePasswordForm";
+import { Link } from "react-router-dom";
+
+import "@src/scss/components/modals/ChangePasswordModal/ChangePasswordModal.scss";
+
+const ChangePasswordModal = ({ closeModal, isOpen }) => {
+  const [passwordChangeSuccessful, setPasswordChangeSuccessful] = React.useState(false);
+  const [identityServiceEnabled, setIdentityServiceEnabled] = React.useState(false);
+
+  const handleClose = () => {
+    closeModal();
+    setPasswordChangeSuccessful(false);
+  }
+
+  const handleSetPasswordChangeSuccessful = (val) => setPasswordChangeSuccessful(val);
+
+  React.useEffect(() => {
+    const getLoginInfo = async () => {
+      try {
+        const response = await fetch(`${process.env.API_ENDPOINT}/login/info`, {
+          headers: {
+            "Content-Type": "application/json",
+          },
+          method: "GET",
+        });
+  
+        if (!response.ok) {
+          const res = await response.json();
+          if (res.error) {
+            throw new Error(`Unexpected status code ${response.status}: ${res.error}`);
+          }
+          throw new Error(`Unexpected status code ${response.status}`);
+        }
+  
+        const loginInfo = await response.json();
+        if (loginInfo?.method === "identity-service") {
+          return setIdentityServiceEnabled(true);
+        }
+        setIdentityServiceEnabled(false);
+      } catch(err) {
+        console.log(err);
+      }
+    }
+    getLoginInfo();
+  }, []);
+
+  const identityServiceContent = (
+    <>
+      <h3 className="u-marginTop--20">Unable to change password</h3>
+      <p className="modal-text">
+        Your session is currently authenticated using an identify provider and must be changed through that identify provider.
+      </p>
+      <button className="btn primary u-marginBottom--20" onClick={handleClose}>
+        OK
+      </button>
+    </>
+  );
+
+  const standardContent = (
+    <>
+      {!passwordChangeSuccessful &&
+        <>
+          <h3>Change Admin Console Password</h3>
+          <Warning>
+            Changing the password for the Admin Console will invalidate and log out of all current sessions. Proceed with caution.
+          </Warning>
+          <ChangePasswordForm handleClose={handleClose} handleSetPasswordChangeSuccessful={handleSetPasswordChangeSuccessful} />
+        </>
+      }
+      {passwordChangeSuccessful &&
+        <>
+          <span className="icon success-checkmark-icon-bright u-marginTop--20" />
+          <h3>Your password has been changed</h3>
+          <p className="modal-text">
+            Password changed successfully. You will be redirected to log in again. Alternatively, click below to log in.
+          </p>
+          <Link to="/secure-console" className="btn primary u-marginBottom--20" onClick={handleClose}>
+            Log in
+          </Link>
+        </>
+      }
+    </>
+  );
+
+  return (
+    <Modal
+      isOpen={isOpen}
+      onRequestClose={handleClose}
+      contentLabel="Change password"
+      ariaHideApp={false}
+      className="Modal MediumSize ChangePasswordModal"
+    >
+      <div className={`Modal-body flex-column ${(passwordChangeSuccessful || identityServiceEnabled) && "alignItems--center"}`}>
+        {identityServiceEnabled
+          ? identityServiceContent
+          : standardContent
+        }
+      </div>
+    </Modal>
+  );
+}
+
+export default ChangePasswordModal;
diff --git a/web/src/components/shared/ChangePassword.jsx b/web/src/components/shared/ChangePassword.jsx
new file mode 100644
index 000000000..8befd9703
--- /dev/null
+++ b/web/src/components/shared/ChangePassword.jsx
@@ -0,0 +1,25 @@
+import React from "react";
+import ChangePasswordModal from "../modals/ChangePasswordModal/ChangePasswordModal";
+
+
+const ChangePassword = () => {
+  const [isOpen, setIsOpen] = React.useState(false);
+
+  const closeModal = () => {
+    setIsOpen(false);
+  }
+
+  return (
+    <>
+      <h1
+        className="FooterItem u-textDecoration--underline u-cursor--pointer"
+        onClick={() => setIsOpen(true)}
+      >
+        Change Password
+      </h1>
+      <ChangePasswordModal isOpen={isOpen} closeModal={closeModal} />
+    </>
+  );
+}
+
+export default ChangePassword;
diff --git a/web/src/components/shared/Footer.jsx b/web/src/components/shared/Footer.jsx
index 71ec3ac20..ab63ecea7 100644
--- a/web/src/components/shared/Footer.jsx
+++ b/web/src/components/shared/Footer.jsx
@@ -1,12 +1,13 @@
 import * as React from "react";
 import { withRouter } from "react-router-dom";
 import semverjs from "semver";
-import { getBuildVersion } from "../../utilities/utilities";
-import "../../scss/components/shared/Footer.scss";
+import { getBuildVersion, Utilities } from "@src/utilities/utilities";
+import "@src/scss/components/shared/Footer.scss";
+import ChangePassword from "./ChangePassword";
 
 export class Footer extends React.Component {
   state = {
-    targetKotsVersion: ""
+    targetKotsVersion: "",
   }
 
   componentDidMount() {
@@ -81,6 +82,7 @@ export class Footer extends React.Component {
               </div>
             }
           </div>
+          {Utilities.isLoggedIn() && <ChangePassword />}
         </div>
       </div>
     );
diff --git a/web/src/components/shared/Warning.jsx b/web/src/components/shared/Warning.jsx
new file mode 100644
index 000000000..e832044df
--- /dev/null
+++ b/web/src/components/shared/Warning.jsx
@@ -0,0 +1,16 @@
+import React from "react";
+
+import "../../scss/components/shared/Warning.scss";
+
+const Warning = ({ children }) => {
+  return (
+    <div className="warning-container flex">
+      <span className="icon errorWarningIcon" />
+      <p className="warning-text">
+        {children}
+      </p>
+    </div>
+  )
+}
+
+export default Warning;
diff --git a/web/src/components/shared/forms/InputField.jsx b/web/src/components/shared/forms/InputField.jsx
new file mode 100644
index 000000000..238001109
--- /dev/null
+++ b/web/src/components/shared/forms/InputField.jsx
@@ -0,0 +1,56 @@
+import React from "react";
+
+import "@src/scss/components/shared/forms/InputField.scss";
+
+const InputField = ({
+  label,
+  placeholder,
+  id,
+  type,
+  value,
+  onChange,
+}) => {
+  const [show, setShow] = React.useState(false);
+
+  const handleToggleShow = () => {
+    setShow(!show);
+  }
+
+  const calculateType = () => {
+    if (type === "password") {
+      return show ? "text" : "password";
+    }
+    return type;
+  }
+
+  const component = (
+    <>
+      <label className={`${id}-label`} htmlFor={id}>{label}</label>
+      <input
+        className="Input"
+        type={calculateType()}
+        id={id}
+        placeholder={placeholder}
+        value={value}
+        onChange={(e) => onChange(e)}
+      />
+      {type === "password" &&
+        <span className="show-password-toggle" onClick={handleToggleShow}>
+          {show ? "hide" : "show"}
+        </span>
+      }
+    </>
+  )
+
+  return (
+    <>
+      {type === "password" ? (
+        <div className="password-input-wrapper flex-column">
+          {component}
+        </div>
+      ) : component}
+    </>
+   )
+}
+
+export default InputField;
diff --git a/web/src/components/snapshots/SnapshotStorageDestination.jsx b/web/src/components/snapshots/SnapshotStorageDestination.jsx
index 894a5c89a..1a32c7b37 100644
--- a/web/src/components/snapshots/SnapshotStorageDestination.jsx
+++ b/web/src/components/snapshots/SnapshotStorageDestination.jsx
@@ -203,7 +203,7 @@ class SnapshotStorageDestination extends Component {
 
   setFields = () => {
     const { snapshotSettings } = this.props;
-    if (!snapshotSettings) return;
+    if (!snapshotSettings) {return;}
     const { store } = snapshotSettings;
 
     if (store?.aws) {
diff --git a/web/src/images/main_spritesheet.svg b/web/src/images/main_spritesheet.svg
index 30a76fe50..34c2d5f14 100644
--- a/web/src/images/main_spritesheet.svg
+++ b/web/src/images/main_spritesheet.svg
@@ -3089,4 +3089,6 @@
 <rect width="63" height="60" fill="white" transform="translate(118.5 452)"/>
 </clipPath>
 </defs>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M25 50C38.8071 50 50 38.8071 50 25C50 11.1929 38.8071 0 25 0C11.1929 0 0 11.1929 0 25C0 38.8071 11.1929 50 25 50Z" fill="#44BB66" transform="translate(252 782)"/>
+<path d="M12.5 25.4864L21.8069 34.0503C22.1879 34.4009 22.781 34.3763 23.1316 33.9953C23.1485 33.9768 23.1648 33.9577 23.1802 33.938L37.5 15.625" stroke="white" stroke-width="4" stroke-linecap="round" transform="translate(252 782)"/>
 </svg>
diff --git a/web/src/scss/components/modals/ChangePasswordModal/ChangePasswordForm.scss b/web/src/scss/components/modals/ChangePasswordModal/ChangePasswordForm.scss
new file mode 100644
index 000000000..e6eff60ca
--- /dev/null
+++ b/web/src/scss/components/modals/ChangePasswordModal/ChangePasswordForm.scss
@@ -0,0 +1,22 @@
+.change-password-form {
+  gap: 12px;
+  width: 100%;
+  
+  label {
+    color: #323232;
+  }
+
+  input {
+    margin-bottom: 8px;
+  }
+
+  .change-password-submit-section {
+    gap: 10px;
+    align-items: center;
+  
+    .change-password-error {
+      font-size: 14px;
+      color: #F65C5C;
+    }
+  }
+}
diff --git a/web/src/scss/components/modals/ChangePasswordModal/ChangePasswordModal.scss b/web/src/scss/components/modals/ChangePasswordModal/ChangePasswordModal.scss
new file mode 100644
index 000000000..a7db72a69
--- /dev/null
+++ b/web/src/scss/components/modals/ChangePasswordModal/ChangePasswordModal.scss
@@ -0,0 +1,17 @@
+.ChangePasswordModal {
+  h3 {
+    font-size: 22px;
+    color: #4A4A4A;
+  }
+
+  .Modal-body {
+    gap: 20px;
+  }
+
+  .modal-text {
+    max-width: 446px;
+    font-size: 14px;
+    line-height: 24px;
+    text-align: center;
+  }
+}
diff --git a/web/src/scss/components/shared/Footer.scss b/web/src/scss/components/shared/Footer.scss
index dcb2411e5..770dd4232 100644
--- a/web/src/scss/components/shared/Footer.scss
+++ b/web/src/scss/components/shared/Footer.scss
@@ -16,7 +16,7 @@ a.FooterItem {
   padding: 0 10px;
   position: relative;
   color: $text-color-body-copy;
-  font-weight: 500;
+  font-weight: 400;
 }
 .FooterItem-wrapper:first-child .FooterItem {
   padding-left: 0;
diff --git a/web/src/scss/components/shared/Warning.scss b/web/src/scss/components/shared/Warning.scss
new file mode 100644
index 000000000..f47fa5277
--- /dev/null
+++ b/web/src/scss/components/shared/Warning.scss
@@ -0,0 +1,19 @@
+.warning-container {
+  padding: 10px;
+  background-color: rgba(247, 181, 0, 0.15);
+  border-radius: 4px;
+  justify-content: space-evenly;
+  align-items: center;
+
+  .icon {
+    width: 100%;
+    max-width: 40px;
+  }
+
+  .warning-text {
+    width: 85%;
+    color: #F7B500;
+    line-height: 24px;
+    font-size: 14px;
+  }
+}
diff --git a/web/src/scss/components/shared/forms/InputField.scss b/web/src/scss/components/shared/forms/InputField.scss
new file mode 100644
index 000000000..67a0cac3d
--- /dev/null
+++ b/web/src/scss/components/shared/forms/InputField.scss
@@ -0,0 +1,13 @@
+.show-password-toggle {
+  color: #326DE6;
+  font-size: 12px;
+  position: absolute;
+  top: 40px;
+  right: 14px;
+  cursor: pointer;
+}
+
+.password-input-wrapper {
+  position: relative;    
+  gap: 12px;
+}
diff --git a/web/src/scss/utilities/icons.scss b/web/src/scss/utilities/icons.scss
index 4ce391fde..bd03bbecc 100644
--- a/web/src/scss/utilities/icons.scss
+++ b/web/src/scss/utilities/icons.scss
@@ -110,6 +110,11 @@
   width: 40px;
   height: 41px;
 }
+.icon.success-checkmark-icon-bright {
+  background-position: -252px -782px;
+  width: 50px;
+  height: 50px;
+}
 .icon.error-checkmark-icon {
   background-position: -42px -229px;
   width: 40px;


Test patch:
diff --git a/pkg/template/static_context_test.go b/pkg/template/static_context_test.go
index 6bff927ee..35f028402 100644
--- a/pkg/template/static_context_test.go
+++ b/pkg/template/static_context_test.go
@@ -7,6 +7,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/require"
+	"gopkg.in/yaml.v2"
 )
 
 func TestStaticContext_kubeSeal_badCert(t *testing.T) {
@@ -152,3 +153,36 @@ func getCert(s string) (*x509.Certificate, error) {
 
 	return x509.ParseCertificate(block.Bytes)
 }
+
+func TestYamlEscape(t *testing.T) {
+	req := require.New(t)
+
+	allchars := ""
+	for i := 0; i <= 255; i++ {
+		allchars += string(byte(i))
+	}
+
+	encoded := StaticCtx{}.yamlEscape(allchars)
+	req.Greater(len(encoded), len(allchars)) // the encoded version will be wrapped in quotes, and have escape characters
+
+	decoded := ""
+	err := yaml.Unmarshal([]byte(encoded), &decoded)
+	req.NoError(err)
+	req.Equal(allchars, decoded)
+
+	exampleYamlTpl := `
+abc:
+  xyz: %s`
+	exampleYaml := fmt.Sprintf(exampleYamlTpl, encoded)
+	type xyz struct {
+		XYZ string `yaml:"xyz"`
+	}
+	type abc struct {
+		ABC xyz `yaml:"abc"`
+	}
+
+	abcTest := abc{}
+	err = yaml.Unmarshal([]byte(exampleYaml), &abcTest)
+	req.NoError(err)
+	req.Equal(allchars, abcTest.ABC.XYZ)
+}