diff --git a/CHANGELOG.md b/CHANGELOG.md
index 019394488af349885614cd5425059465ec51cdd2..096dbb5a5bf8e317f976934d6622ea8567d323ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,23 @@
 # Changelog
 
+## [0.6.2]
+
+- Fix submit button label in the form for verifying your TOTP code.
+
+## [0.6.1]
+
+- Add TOTP as second factor authentication. Please note that you'll need to set
+  a `backend.dashboardUrl` value instead of the old `backend.loginPanelUrl` one
+  -- typically dropping the `/web` suffix to get the new value.
+- Create a new backend endpoint for providing some environment variables to the
+  frontend, with the URLs of the Kratos and Hydra APIs.
+
+## [0.6.0]
+
+- Make it easier to add apps, by reading apps and oauthclients from configmaps
+  at startup.
+- Reset alembic migration history.
+
 ## [0.5.2]
 
 - Fix login welcome message
diff --git a/README.md b/README.md
index 58babdfcf403cca5c3e8aa89d18fa9c67929af6e..f7b4959eb46d8e00c4ec1f33c2c0f731a1ac0687 100644
--- a/README.md
+++ b/README.md
@@ -67,9 +67,10 @@ These need to be available locally, because Kratos wants to run on the same
 domain as the front-end that serves the login interface.
 
 ### Setup
+Before you start, make sure your machine has the required software installed, as per official documentation: https://docs.stackspin.net/en/v2/installation/install_cli.html#preparing-the-provisioning-machine.
 
 Please read through all subsections to set up your environment before
-attempting to run the dashboard locally.
+attempting to run the dashboard locally. 
 
 #### 1. Stackspin cluster
 
@@ -90,8 +91,6 @@ configure it, create a `local.env` file in the `frontend` directory:
 
     cp local.env.example local.env
 
-and adjust the `REACT_APP_HYDRA_PUBLIC_URL` to the SSO URL of your cluster.
-
 #### 3. Setup hosts file
 
 The application will run on `http://stackspin_proxy`. Add the following line to
@@ -104,7 +103,8 @@ The application will run on `http://stackspin_proxy`. Add the following line to
 #### 4. Kubernetes access
 
 The script needs you to have access to the Kubernetes cluster that runs
-Stackspin. Point the `KUBECONFIG` environment variable to a kubectl config. Attention points:
+Stackspin. Point the `KUBECONFIG` environment variable to a kubectl config.
+Attention points:
 
 * The kubeconfig will be mounted inside docker containers, so also make sure
   your Docker user can read it.
@@ -121,3 +121,23 @@ After you've finished all setup steps, you can run everything using
 This sets a few environment variables based on what is in your cluster
 secrets, and run `docker compose up` to build and run all necessary components,
 including a reverse proxy and the backend flask application.
+
+## Testing as a part of Stackspin
+
+Sometimes you may want to make more fundamental changes to the dashboard that
+might behave differently in the local development environment compared to a
+regular Stackspin instance, i.e., one that's not a local/cluster hybrid. In
+this case, you'll want to run your new version in a regular Stackspin cluster.
+
+To do that, make sure to increase the chart version number in `Chart.yaml`, and
+push your work to a MR. The CI pipeline should then publish your new chart
+version in the Gitlab helm chart repo for the dashboard project, but in the
+`unstable` channel -- the `stable` channel is reserved for chart versions that
+have been merged to the `main` branch.
+
+Once your package is published, use it by
+1. changing the `spec.url` field of the `flux-system/dashboard`
+   `HelmRepository` object in the cluster where you want to run this, replacing
+   `stable` by `unstable`; and
+2. changing the `spec.chart.spec.version` field of the `stackspin/dashboard`
+   `HelmRelease` to your chart version (the one from this chart's `Chart.yaml`).
diff --git a/backend/Dockerfile b/backend/Dockerfile
index c62abf74718d46c6a1825032ce60e84b44f3b84f..67b11bb62970ef62261b83e1adf933322de99af2 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -1,20 +1,24 @@
 FROM python:3.11-slim
 
-## make a local directory
-RUN mkdir /app
-
 # set "app" as the working directory from which CMD, RUN, ADD references
 WORKDIR /app
 
-# now copy all the files in this directory to /app
-COPY . .
-
+# First install apt packages, so we can cache this even if requirements.txt
+# changes.
 # hadolint ignore=DL3008
 RUN apt-get update \
   && apt-get install --no-install-recommends -y gcc g++ libffi-dev libc6-dev \
   && apt-get clean \
-  && rm -rf /var/lib/apt/lists/* \
-  && pip install --no-cache-dir -r requirements.txt
+  && rm -rf /var/lib/apt/lists/*
+
+# Now copy the python dependencies specification.
+COPY requirements.txt .
+
+# Install python dependencies.
+RUN pip install --no-cache-dir -r requirements.txt
+
+# now copy all the files in this directory to /app
+COPY . .
 
 # Listen to port 80 at runtime
 EXPOSE 5000
diff --git a/backend/app.py b/backend/app.py
index 622173723d1215ddac05ddd36bfce17c8a8018b8..896f1035fd748df697776c66690ada75f16bd637 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -64,7 +64,6 @@ app.config["SQLALCHEMY_DATABASE_URI"] = SQLALCHEMY_DATABASE_URI
 app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = SQLALCHEMY_TRACK_MODIFICATIONS
 
 app.logger.setLevel(logging.INFO)
-app.logger.info("Starting dashboard backend.")
 
 cors = CORS(app)
 
@@ -121,7 +120,6 @@ jwt = JWTManager(app)
 def expired_token_callback(*args):
     return jsonify({"errorMessage": "Unauthorized"}), 401
 
-
 @app.route("/")
 def index():
     return "Stackspin API v1.0"
diff --git a/backend/areas/__init__.py b/backend/areas/__init__.py
index ae4261edec5b9a87f68832c8484f480d380cc0f4..b90dfee2fde7711293a5e0a3394548182bcab958 100644
--- a/backend/areas/__init__.py
+++ b/backend/areas/__init__.py
@@ -1,4 +1,6 @@
-from flask import Blueprint
+from flask import Blueprint, jsonify
+
+from config import *
 
 api_v1 = Blueprint("api_v1", __name__, url_prefix="/api/v1")
 
@@ -7,3 +9,11 @@ api_v1 = Blueprint("api_v1", __name__, url_prefix="/api/v1")
 @api_v1.route("/health")
 def api_index():
     return "Stackspin API v1.0"
+
+@api_v1.route("/environment")
+def api_environment():
+    environment = {
+        "HYDRA_PUBLIC_URL": HYDRA_PUBLIC_URL,
+        "KRATOS_PUBLIC_URL": KRATOS_PUBLIC_URL,
+    }
+    return jsonify(environment)
diff --git a/backend/areas/apps/apps.py b/backend/areas/apps/apps.py
index 1bd55644f0a7b985062e8897423ed430318b247c..aa61d0417b932ec9bae5c3c422a3f90b87ca5ddd 100644
--- a/backend/areas/apps/apps.py
+++ b/backend/areas/apps/apps.py
@@ -29,6 +29,7 @@ def get_apps():
 
 @api_v1.route('/apps/<string:slug>', methods=['GET'])
 @jwt_required()
+@cross_origin()
 def get_app(slug):
     """Return data about a single app"""
     app = AppsService.get_app(slug)
diff --git a/backend/areas/apps/apps_service.py b/backend/areas/apps/apps_service.py
index 289529de75435c538e4796a36945a9ddaeab9d83..04958aa884b76119ac390dbda33927438ae950fa 100644
--- a/backend/areas/apps/apps_service.py
+++ b/backend/areas/apps/apps_service.py
@@ -1,7 +1,7 @@
 from flask import current_app
 from flask_jwt_extended import get_jwt
 import ory_kratos_client
-from ory_kratos_client.api import v0alpha2_api as kratos_api
+from ory_kratos_client.api import identity_api
 
 from .models import App, AppRole
 from config import *
@@ -19,18 +19,19 @@ class AppsService:
         apps = App.query.all()
 
         kratos_admin_api_configuration = ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
-        KRATOS_ADMIN = kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
-
-        user_id = get_jwt()['user_id']
-        current_app.logger.info(f"user_id: {user_id}")
-        # Get the related user object
-        current_app.logger.info(f"Info: Getting user from admin {user_id}")
-        user = KratosUser(KRATOS_ADMIN, user_id)
-        if not user:
-            current_app.logger.error(f"User not found in database: {user_id}")
-            return []
-
-        return [app.to_dict() for app in apps if user_has_access(user, app)]
+        with ory_kratos_client.ApiClient(kratos_admin_api_configuration) as kratos_admin_client:
+            kratos_identity_api = identity_api.IdentityApi(kratos_admin_client)
+
+            user_id = get_jwt()['user_id']
+            current_app.logger.info(f"user_id: {user_id}")
+            # Get the related user object
+            current_app.logger.info(f"Info: Getting user from admin {user_id}")
+            user = KratosUser(kratos_identity_api, user_id)
+            if not user:
+                current_app.logger.error(f"User not found in database: {user_id}")
+                return []
+
+            return [app.to_dict() for app in apps if user_has_access(user, app)]
 
     @staticmethod
     def get_app(slug):
diff --git a/backend/areas/roles/role_service.py b/backend/areas/roles/role_service.py
index 3520b273e75026410d095ccbf7df26a6b097728b..77943f75c1f8409784d37c40e234b7bc9205731c 100644
--- a/backend/areas/roles/role_service.py
+++ b/backend/areas/roles/role_service.py
@@ -1,4 +1,4 @@
-from areas.apps.models import AppRole
+from areas.apps.models import App, AppRole
 from .models import Role
 
 
@@ -14,5 +14,6 @@ class RoleService:
 
     @staticmethod
     def is_user_admin(userId):
-        dashboard_role_id = AppRole.query.filter_by(user_id=userId, app_id=1).first().role_id
+        dashboard_app_id = App.query.filter_by(slug='dashboard').first().id
+        dashboard_role_id = AppRole.query.filter_by(user_id=userId, app_id=dashboard_app_id).first().role_id
         return dashboard_role_id == 1
diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py
index ec94c44ce44bee2e33013e3e7b1ee4d55b31cb21..99169f5656437150e21bc611c0a08812288feb36 100644
--- a/backend/areas/users/user_service.py
+++ b/backend/areas/users/user_service.py
@@ -1,7 +1,7 @@
 import ory_kratos_client
-from ory_kratos_client.model.submit_self_service_recovery_flow_body \
-    import SubmitSelfServiceRecoveryFlowBody
-from ory_kratos_client.api import v0alpha2_api as kratos_api
+from ory_kratos_client.model.update_recovery_flow_body \
+    import UpdateRecoveryFlowBody
+from ory_kratos_client.api import frontend_api, identity_api
 from config import KRATOS_ADMIN_URL
 
 from database import db
@@ -15,8 +15,9 @@ from helpers.error_handler import KratosError
 
 kratos_admin_api_configuration = \
     ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
-KRATOS_ADMIN = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
+kratos_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
+kratos_frontend_api = frontend_api.FrontendApi(kratos_client)
+kratos_identity_api = identity_api.IdentityApi(kratos_client)
 
 class UserService:
     @staticmethod
@@ -68,6 +69,8 @@ class UserService:
                 db.session.add(app_role)
             db.session.commit()
 
+        # We start a recovery flow immediately after creating the
+        # user, so the user can set their initial password.
         UserService.__start_recovery_flow(data["email"])
 
         return UserService.get_user(res["id"])
@@ -85,14 +88,13 @@ class UserService:
         :param email: Email to send recovery link to
         :type email: str
         """
-        api_response = KRATOS_ADMIN.initialize_self_service_recovery_flow_without_browser()
+        api_response = kratos_frontend_api.create_native_recovery_flow()
         flow = api_response['id']
         # Submit the recovery flow to send an email to the new user.
-        submit_self_service_recovery_flow_body = \
-            SubmitSelfServiceRecoveryFlowBody(method="link", email=email)
-        api_response = KRATOS_ADMIN.submit_self_service_recovery_flow(flow,
-                submit_self_service_recovery_flow_body=
-                    submit_self_service_recovery_flow_body)
+        update_recovery_flow_body = \
+            UpdateRecoveryFlowBody(method="link", email=email)
+        api_response = kratos_frontend_api.update_recovery_flow(flow,
+                update_recovery_flow_body=update_recovery_flow_body)
 
     @staticmethod
     def put_user(id, user_editing_id, data):
@@ -181,15 +183,19 @@ class UserService:
         apps = App.query.all()
         app_roles = []
         for app in apps:
-            tmp_app_role = AppRole.query.filter_by(
-                user_id=userId, app_id=app.id
-            ).first()
-            app_roles.append(
-                {
-                    "name": app.slug,
-                    "role_id": tmp_app_role.role_id if tmp_app_role else None,
-                }
-            )
+            # Only show role when installed
+            app_status = app.get_status()
+            if app_status.installed:
+
+                tmp_app_role = AppRole.query.filter_by(
+                    user_id=userId, app_id=app.id
+                ).first()
+                app_roles.append(
+                        {
+                            "name": app.slug,
+                            "role_id": tmp_app_role.role_id if tmp_app_role else None,
+                        }
+                    )
 
         userRes["traits"]["app_roles"] = app_roles
         return userRes
diff --git a/backend/areas/users/users.py b/backend/areas/users/users.py
index 08f22c6f5f9266e6efc2c9dbfcfcb115a6501388..b1740ebd57eba73348a1ac4127d2f8652261368c 100644
--- a/backend/areas/users/users.py
+++ b/backend/areas/users/users.py
@@ -1,7 +1,7 @@
 from flask import jsonify, request
-from flask_jwt_extended import get_jwt, jwt_required
 from flask_cors import cross_origin
 from flask_expects_json import expects_json
+from flask_jwt_extended import get_jwt, jwt_required
 
 from areas import api_v1
 from helpers import KratosApi
diff --git a/backend/cliapp/cliapp/cli.py b/backend/cliapp/cliapp/cli.py
index 3d2fedc6f964b8a9984590663b853e9a6c66ec6f..4c8305c562e0168862a8be7b68957959cbb61e31 100644
--- a/backend/cliapp/cliapp/cli.py
+++ b/backend/cliapp/cliapp/cli.py
@@ -7,11 +7,11 @@ the user entries in the database(s)"""
 import sys
 
 import click
-import hydra_client
+import ory_hydra_client
 import ory_kratos_client
 from flask import current_app
 from flask.cli import AppGroup
-from ory_kratos_client.api import v0alpha2_api as kratos_api
+from ory_kratos_client.api import identity_api
 from sqlalchemy import func
 
 from config import HYDRA_ADMIN_URL, KRATOS_ADMIN_URL, KRATOS_PUBLIC_URL
@@ -22,21 +22,14 @@ from areas.apps import AppRole, App
 from database import db
 
 # APIs
-# Create HYDRA & KRATOS API interfaces
-HYDRA = hydra_client.HydraAdmin(HYDRA_ADMIN_URL)
 
-# Kratos has an admin and public end-point. We create an API for them
-# both. The kratos implementation has bugs, which forces us to set
-# the discard_unknown_keys to True.
+# Kratos has an admin and public end-point. We create an API for the admin one.
+# The kratos implementation has bugs, which forces us to set the
+# discard_unknown_keys to True.
 kratos_admin_api_configuration = \
     ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
-KRATOS_ADMIN = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
-
-kratos_public_api_configuration = \
-    ory_kratos_client.Configuration(host=KRATOS_PUBLIC_URL, discard_unknown_keys=True)
-KRATOS_PUBLIC = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_public_api_configuration))
+kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
+kratos_identity_api = identity_api.IdentityApi(kratos_admin_client)
 
 ##############################################################################
 # CLI INTERFACE                                                              #
@@ -61,9 +54,7 @@ def create_app(slug, name, external_url = None):
     """
     current_app.logger.info(f"Creating app definition: {name} ({slug}")
 
-    obj = App()
-    obj.name = name
-    obj.slug = slug
+    obj = App(name=name, slug=slug)
 
     app_obj = App.query.filter_by(slug=slug).first()
 
@@ -213,7 +204,7 @@ def setrole(email, app_slug, role):
     current_app.logger.info(f"Assigning role {role} to {email} for app {app_slug}")
 
     # Find user
-    user = KratosUser.find_by_email(KRATOS_ADMIN, email)
+    user = KratosUser.find_by_email(kratos_identity_api, email)
 
     if role not in ("admin", "user"):
         print("At this point only the roles 'admin' and 'user' are accepted")
@@ -256,7 +247,7 @@ def show_user(email):
     internal state/values of the user object
     :param email: Email address of the user to show
     """
-    user = KratosUser.find_by_email(KRATOS_ADMIN, email)
+    user = KratosUser.find_by_email(kratos_identity_api, email)
     if user is not None:
         print(user)
         print("")
@@ -288,7 +279,7 @@ def update_user(email, field, value):
     :param value: The value to set the field with
     """
     current_app.logger.info(f"Looking for user with email: {email}")
-    user = KratosUser.find_by_email(KRATOS_ADMIN, email)
+    user = KratosUser.find_by_email(kratos_identity_api, email)
     if not user:
         current_app.logger.error(f"User with email {email} not found.")
         sys.exit(1)
@@ -310,7 +301,7 @@ def delete_user(email):
     :param email: Email address of user to delete
     """
     current_app.logger.info(f"Looking for user with email: {email}")
-    user = KratosUser.find_by_email(KRATOS_ADMIN, email)
+    user = KratosUser.find_by_email(kratos_identity_api, email)
     if not user:
         current_app.logger.error(f"User with email {email} not found.")
         sys.exit(1)
@@ -327,12 +318,12 @@ def create_user(email):
     current_app.logger.info(f"Creating user with email: ({email})")
 
     # Create a user
-    user = KratosUser.find_by_email(KRATOS_ADMIN, email)
+    user = KratosUser.find_by_email(kratos_identity_api, email)
     if user:
         current_app.logger.info("User already exists. Not recreating")
         return
 
-    user = KratosUser(KRATOS_ADMIN)
+    user = KratosUser(kratos_identity_api)
     user.email = email
     user.save()
 
@@ -357,7 +348,7 @@ def setpassword_user(email, password):
 
     try:
         # Get the ID of the user
-        kratos_user = KratosUser.find_by_email(KRATOS_ADMIN, email)
+        kratos_user = KratosUser.find_by_email(kratos_identity_api, email)
         if kratos_user is None:
             current_app.logger.error(f"User with email '{email}' not found")
             sys.exit(1)
@@ -382,7 +373,7 @@ def setpassword_user(email, password):
 def list_user():
     """Show a list of users in the database"""
     current_app.logger.info("Listing users")
-    users = KratosUser.find_all(KRATOS_ADMIN)
+    users = KratosUser.find_all(kratos_identity_api)
 
     for obj in users:
         print(obj)
@@ -399,7 +390,7 @@ def recover_user(email):
 
     try:
         # Get the ID of the user
-        kratos_user = KratosUser.find_by_email(KRATOS_ADMIN, email)
+        kratos_user = KratosUser.find_by_email(kratos_identity_api, email)
 
         # Get a recovery URL
         url = kratos_user.get_recovery_link()
diff --git a/backend/cluster_config.py b/backend/cluster_config.py
index 20ec9e98023dfd01b46a30492f74057aea1a1aed..f814e1c4351e9e3ab56cefc07ce77146fb7d6d57 100644
--- a/backend/cluster_config.py
+++ b/backend/cluster_config.py
@@ -13,7 +13,7 @@ def populate_apps():
     for app in App.query.all():
         slug = app.slug
         database_apps[slug] = app
-        logging.info(f"database app: {slug}")
+        logging.debug(f"database app: {slug}")
     _populate_apps_from(database_apps, "stackspin-apps")
     _populate_apps_from(database_apps, "stackspin-apps-custom")
 
@@ -27,19 +27,19 @@ def _populate_apps_from(database_apps, configmap_name):
         logging.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
     else:
         for app_slug, app_data in cm_apps.items():
-            logging.info(f"configmap app: {app_slug}")
+            logging.debug(f"configmap app: {app_slug}")
             if app_slug in database_apps:
-                logging.info(f"  already present in database")
+                logging.debug(f"  already present in database")
             else:
-                logging.info(f"  not present in database, adding!")
+                logging.debug(f"  not present in database, adding!")
                 data = yaml.safe_load(app_data)
                 name = data["name"]
-                logging.info(f"  name: {name}")
+                logging.debug(f"  name: {name}")
                 external = data.get("external", False)
-                logging.info(f"  type external: {type(external)}")
-                logging.info(f"  external: {external}")
+                logging.debug(f"  type external: {type(external)}")
+                logging.debug(f"  external: {external}")
                 url = data.get("url", None)
-                logging.info(f"  url: {url}")
+                logging.debug(f"  url: {url}")
                 new_app = App(slug=app_slug, name=name, external=external, url=url)
                 db.session.add(new_app)
         db.session.commit()
@@ -52,7 +52,7 @@ def populate_oauthclients():
     for client in OAuthClientApp.query.all():
         id = client.oauthclient_id
         database_oauthclients[id] = client
-        logging.info(f"database oauthclient: {id}")
+        logging.debug(f"database oauthclient: {id}")
     _populate_oauthclients_from(database_oauthclients, "stackspin-oauthclients")
     _populate_oauthclients_from(database_oauthclients, "stackspin-oauthclients-custom")
 
@@ -65,11 +65,11 @@ def _populate_oauthclients_from(database_oauthclients, configmap_name):
         logging.info(f"Could not find configmap '{configmap_name}' in namespace 'flux-system'; ignoring.")
     else:
         for client_id, client_app in cm_oauthclients.items():
-            logging.info(f"configmap oauthclient: {client_id}")
+            logging.debug(f"configmap oauthclient: {client_id}")
             if client_id in database_oauthclients:
-                logging.info(f"  already present in database")
+                logging.debug(f"  already present in database")
             else:
-                logging.info(f"  not present in database, adding!")
+                logging.debug(f"  not present in database, adding!")
                 # Take the value of the configmap mapping (`client_app`) and
                 # interpret it as the slug of the app that this oauthclient
                 # belongs to.
@@ -78,6 +78,6 @@ def _populate_oauthclients_from(database_oauthclients, configmap_name):
                     logging.error(f"  could not find app with slug {client_app}")
                     continue
                 new_client = OAuthClientApp(oauthclient_id=client_id, app_id=app.id)
-                logging.info(f"  new oauth client: {new_client}")
+                logging.debug(f"  new oauth client: {new_client}")
                 db.session.add(new_client)
         db.session.commit()
diff --git a/backend/config.py b/backend/config.py
index 2cb001778303acb3efbff62c07d9e07b1e797904..6e5d37ac1221925e8403612674225bd0ccf5314d 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -6,6 +6,7 @@ HYDRA_CLIENT_SECRET = os.environ.get("HYDRA_CLIENT_SECRET")
 HYDRA_AUTHORIZATION_BASE_URL = os.environ.get("HYDRA_AUTHORIZATION_BASE_URL")
 TOKEN_URL = os.environ.get("TOKEN_URL")
 
+DASHBOARD_URL = os.environ.get("DASHBOARD_URL")
 LOGIN_PANEL_URL = os.environ.get("LOGIN_PANEL_URL")
 
 HYDRA_PUBLIC_URL = os.environ.get("HYDRA_PUBLIC_URL")
@@ -20,3 +21,5 @@ SQLALCHEMY_TRACK_MODIFICATIONS = False
 # running in a Kubernetes pod. Set it to "false" to load the config from the
 # `KUBECONFIG` environment variable.
 LOAD_INCLUSTER_CONFIG = os.environ.get("LOAD_INCLUSTER_CONFIG").lower() == "true"
+
+DEMO_INSTANCE = os.environ.get("DASHBOARD_DEMO_INSTANCE", "False").lower() in ('true', '1')
diff --git a/backend/helpers/kratos_user.py b/backend/helpers/kratos_user.py
index dee31b4c229a513154c55eb12d72c0c1d7a65766..3615809e0adcd29abcdd167a69bb4ca6cf7aafb0 100644
--- a/backend/helpers/kratos_user.py
+++ b/backend/helpers/kratos_user.py
@@ -9,10 +9,10 @@ import urllib.request
 from typing import Dict
 from urllib.request import Request
 
-from ory_kratos_client.model.admin_create_identity_body import AdminCreateIdentityBody
-from ory_kratos_client.model.admin_create_self_service_recovery_link_body \
-    import AdminCreateSelfServiceRecoveryLinkBody
-from ory_kratos_client.model.admin_update_identity_body import AdminUpdateIdentityBody
+from ory_kratos_client.model.create_identity_body import CreateIdentityBody
+from ory_kratos_client.model.create_recovery_link_for_identity_body \
+    import CreateRecoveryLinkForIdentityBody
+from ory_kratos_client.model.update_identity_body import UpdateIdentityBody
 from ory_kratos_client.rest import ApiException as KratosApiException
 
 from .classes import RedirectFilter
@@ -39,7 +39,7 @@ class KratosUser():
         self.state = 'active'
         if uuid:
             try:
-                obj = api.admin_get_identity(uuid)
+                obj = api.get_identity(uuid)
                 if obj:
                     self.__uuid = uuid
                     try:
@@ -82,26 +82,26 @@ class KratosUser():
 
         # If we have a UUID, we are updating
         if self.__uuid:
-            body = AdminUpdateIdentityBody(
+            body = UpdateIdentityBody(
                 schema_id="default",
                 state=self.state,
                 traits=traits,
             )
             try:
-                api_response = self.api.admin_update_identity(self.__uuid,
-                        admin_update_identity_body=body)
+                api_response = self.api.update_identity(self.__uuid,
+                        update_identity_body=body)
             except KratosApiException as error:
                 raise BackendError(f"Unable to save entry, kratos replied with:{error}") from error
         else:
 
-            body = AdminCreateIdentityBody(
+            body = CreateIdentityBody(
                 schema_id="default",
                 traits=traits,
             )
             try:
                 # Create an Identity
-                api_response = self.api.admin_create_identity(
-                        admin_create_identity_body=body)
+                api_response = self.api.create_identity(
+                        create_identity_body=body)
                 if api_response.id:
                     self.__uuid = api_response.id
             except KratosApiException as error:
@@ -113,7 +113,7 @@ class KratosUser():
         """
         if self.__uuid:
             try:
-                self.api.admin_delete_identity(self.__uuid)
+                self.api.delete_identity(self.__uuid)
                 return True
             except KratosApiException as error:
                 raise BackendError(
@@ -133,8 +133,8 @@ class KratosUser():
         kratos_id = None
 
         # Get out user ID by iterating over all available IDs
-        data = api.admin_list_identities()
-        for kratos_obj in data.value:
+        data = api.list_identities()
+        for kratos_obj in data:
             # Unique identifier we use
             if kratos_obj.traits['email'] == email:
                 kratos_id = str(kratos_obj.id)
@@ -152,8 +152,8 @@ class KratosUser():
         kratos_id = None
         return_list = []
         # Get out user ID by iterating over all available IDs
-        data = api.admin_list_identities()
-        for kratos_obj in data.value:
+        data = api.list_identities()
+        for kratos_obj in data:
             kratos_id = str(kratos_obj.id)
             return_list.append(KratosUser(api, kratos_id))
 
@@ -200,14 +200,14 @@ class KratosUser():
 
         try:
             # Create body request to get recovery link with admin API
-            body = AdminCreateSelfServiceRecoveryLinkBody(
+            body = CreateRecoveryLinkForIdentityBody(
                 expires_in="15m",
                 identity_id=self.__uuid
             )
 
             # Get recovery link from admin API
-            call = self.api.admin_create_self_service_recovery_link(
-                admin_create_self_service_recovery_link_body=body)
+            call = self.api.create_recovery_link_for_identity(
+                create_recovery_link_for_identity_body=body)
 
             url = call.recovery_link
         except KratosApiException:
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 05715b0ea460384fee1dd9668bd3c9810ed849ce..fc2836ad096a8f50d217569cc0a9b8eccdc5db34 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -9,6 +9,8 @@ Flask==2.0.3
 Flask-Cors==3.0.10
 flask-expects-json==1.7.0
 Flask-JWT-Extended==4.3.1
+Flask-Migrate==4.0.1
+Flask-SQLAlchemy==2.5.1
 gunicorn==20.1.0
 idna==3.3
 install==1.3.5
@@ -21,10 +23,13 @@ MarkupSafe==2.1.1
 mypy-extensions==0.4.3
 NamedAtomicLock==1.1.3
 oauthlib==3.2.0
+ory-kratos-client==0.11.0
+ory-hydra-client==1.11.8
 pathspec==0.9.0
 platformdirs==2.5.1
 pycparser==2.21
 PyJWT==2.3.0
+pymysql==1.0.2
 pyrsistent==0.18.1
 PyYAML==6.0
 regex==2022.3.15
@@ -35,8 +40,3 @@ tomli==1.2.3
 typing-extensions==4.1.1
 urllib3==1.26.8
 Werkzeug==2.0.3
-ory-kratos-client==0.9.0a2
-pymysql
-Flask-SQLAlchemy
-hydra-client
-Flask-Migrate
diff --git a/backend/web/login/login.py b/backend/web/login/login.py
index 94ee4c37036fce38e47dcfe7c70e8ba87f4aac2d..25f09a006ace2a225fdf17d3f674a810a6a64fa4 100644
--- a/backend/web/login/login.py
+++ b/backend/web/login/login.py
@@ -8,10 +8,15 @@ import urllib.parse
 import urllib.request
 import ast
 
-import hydra_client
+import ory_hydra_client
+# hydra v2
+# from ory_hydra_client.api import o_auth2_api
+from ory_hydra_client.api import admin_api
+from ory_hydra_client.models import AcceptConsentRequest, AcceptLoginRequest, ConsentRequestSession
+import ory_hydra_client.exceptions as hydra_exceptions
 import ory_kratos_client
-from ory_kratos_client.api import v0alpha2_api as kratos_api
-from flask import abort, redirect, render_template, request, current_app
+from ory_kratos_client.api import frontend_api, identity_api
+from flask import abort, current_app, jsonify, redirect, render_template, request
 
 from database import db
 from helpers import KratosUser
@@ -19,6 +24,8 @@ from config import *
 from web import web
 from areas.apps import AppRole, App, OAuthClientApp
 from areas.roles import RoleService
+from areas.roles.models import Role
+from areas.users.user_service import UserService
 
 
 # This is a circular import and should be solved differently
@@ -26,20 +33,27 @@ from areas.roles import RoleService
 
 # APIs
 # Create HYDRA & KRATOS API interfaces
-HYDRA = hydra_client.HydraAdmin(HYDRA_ADMIN_URL)
+hydra_admin_api_configuration = \
+    ory_hydra_client.Configuration(host=HYDRA_ADMIN_URL, discard_unknown_keys=True)
+hydra_client = ory_hydra_client.ApiClient(hydra_admin_api_configuration)
+# hydra v2
+# oauth2_api = o_auth2_api.OAuth2Api(hydra_client)
+hydra_admin_api = admin_api.AdminApi(hydra_client)
 
 # Kratos has an admin and public end-point. We create an API for them
 # both. The kratos implementation has bugs, which forces us to set
 # the discard_unknown_keys to True.
 kratos_admin_api_configuration = \
     ory_kratos_client.Configuration(host=KRATOS_ADMIN_URL, discard_unknown_keys=True)
-KRATOS_ADMIN = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_admin_api_configuration))
+kratos_admin_client = ory_kratos_client.ApiClient(kratos_admin_api_configuration)
+admin_identity_api = identity_api.IdentityApi(kratos_admin_client)
+admin_frontend_api = frontend_api.FrontendApi(kratos_admin_client)
 
 kratos_public_api_configuration = \
     ory_kratos_client.Configuration(host=KRATOS_PUBLIC_URL, discard_unknown_keys=True)
-KRATOS_PUBLIC = \
-    kratos_api.V0alpha2Api(ory_kratos_client.ApiClient(kratos_public_api_configuration))
+kratos_public_client = ory_kratos_client.ApiClient(kratos_public_api_configuration)
+kratos_public_frontend_api = frontend_api.FrontendApi(kratos_public_client)
+
 ADMIN_ROLE_ID = 1
 NO_ACCESS_ROLE_ID = 3
 
@@ -61,7 +75,7 @@ def recovery():
     if not flow:
         return redirect(KRATOS_PUBLIC_URL + "self-service/recovery/browser")
 
-    return render_template("recover.html", api_url=KRATOS_PUBLIC_URL)
+    return render_template("recover.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL)
 
 
 @web.route("/settings", methods=["GET", "POST"])
@@ -77,7 +91,7 @@ def settings():
     if not flow:
         return redirect(KRATOS_PUBLIC_URL + "self-service/settings/browser")
 
-    return render_template("settings.html", api_url=KRATOS_PUBLIC_URL)
+    return render_template("settings.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL)
 
 
 @web.route("/error", methods=["GET"])
@@ -95,13 +109,13 @@ def error():
     api_response = ""
     try:
         # Get Self-Service Errors
-        api_response = KRATOS_ADMIN.get_self_service_error(error_id)
+        api_response = admin_frontend_api.get_flow_error(error_id)
     except ory_kratos_client.ApiException as ex:
         current_app.logger.error(
-            "Exception when calling V0alpha2Api->get_self_service_error: %s\n",
+            "Exception when calling get_self_service_error: %s\n",
             ex)
 
-    return render_template("error.html", error_message=api_response)
+    return render_template("error.html", dashboard_url=DASHBOARD_URL, error_message=api_response)
 
 
 @web.route("/login", methods=["GET", "POST"])
@@ -117,22 +131,37 @@ def login():
     # Check if we are logged in:
     identity = get_auth()
 
-    if identity:
+    refresh = False
+    flow = request.args.get("flow")
+    if flow:
+        cookies = request.headers['cookie']
+        flow = kratos_public_frontend_api.get_login_flow(flow, cookie=cookies)
+        refresh = flow['refresh']
+
+    if identity and not refresh:
+        # We are already logged in, and don't need to refresh.
         if 'name' in identity['traits']:
             # Add a space in front of the "name" so the template can put it
             # between "Welcome" and the comma
             name = " " + identity['traits']['name']
         else:
             name = ""
-        return render_template("loggedin.html", api_url=KRATOS_PUBLIC_URL, name=name)
-
-    flow = request.args.get("flow")
+        return render_template("loggedin.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL, name=name)
 
     # If we do not have a flow, get one.
     if not flow:
         return redirect(KRATOS_PUBLIC_URL + "self-service/login/browser")
 
-    return render_template("login.html", api_url=KRATOS_PUBLIC_URL)
+    # If we end up here, then either:
+    # `identity and refresh`
+    #     User is already logged in, but "refresh" is specified, meaning that
+    #     we should ask the user to authenticate again. This is necessary when
+    #     you want to change protected fields (password, TOTP) in the
+    #     self-service settings, and your session is too old.
+    # or `not identity`
+    #     User is not logged in yet.
+    # In either case, we present the login screen now.
+    return render_template("login.html", api_url=KRATOS_PUBLIC_URL, dashboard_url=DASHBOARD_URL, refresh=refresh, demo=DEMO_INSTANCE)
 
 
 @web.route("/auth", methods=["GET", "POST"])
@@ -185,13 +214,15 @@ def auth():
     current_app.logger.info("User is logged in. We can authorize the user")
 
     try:
-        login_request = HYDRA.login_request(challenge)
-    except hydra_client.exceptions.NotFound:
+        # hydra v2
+        # login_request = oauth2_api.get_o_auth2_login_request(challenge)
+        login_request = hydra_admin_api.get_login_request(challenge)
+    except hydra_exceptions.NotFoundException:
         current_app.logger.error(
             f"Not Found. Login request not found. challenge={challenge}"
         )
         abort(404, description="Login request not found. Please try again.")
-    except hydra_client.exceptions.HTTPError:
+    except hydra_exceptions.ApiException:
         current_app.logger.error(
             f"Conflict. Login request has been used already. challenge={challenge}"
         )
@@ -199,12 +230,21 @@ def auth():
 
     # Authorize the user
     # False positive: pylint: disable=no-member
-    redirect_to = login_request.accept(
-        identity.id,
-        remember=True,
-        # Remember session for 7d
-        remember_for=60 * 60 * 24 * 7,
-    )
+
+    try:
+        redirect_to = hydra_admin_api.accept_login_request(
+            challenge,
+            accept_login_request=AcceptLoginRequest(
+                identity.id,
+                remember=True,
+                # Remember session for 7d
+                remember_for=60 * 60 * 24 * 7,
+            )
+        ).redirect_to
+    except Exception as e:
+        current_app.logger.error("Failure during accepting login request. Redirecting to logout, hopefully to wipe cookies")
+        current_app.logger.error(e)
+        return redirect("logout")
 
     return redirect(redirect_to)
 
@@ -224,11 +264,13 @@ def consent():
             403, description="Consent request required. Do not call this page directly"
         )
     try:
-        consent_request = HYDRA.consent_request(challenge)
-    except hydra_client.exceptions.NotFound:
+        # hydra v2
+        # consent_request = oauth2_api.get_o_auth2_consent_request(challenge)
+        consent_request = hydra_admin_api.get_consent_request(challenge)
+    except hydra_exceptions.NotFoundException:
         current_app.logger.error(f"Not Found. Consent request {challenge} not found")
         abort(404, description="Consent request does not exist. Please try again")
-    except hydra_client.exceptions.HTTPError:
+    except hydra_exceptions.ApiException:
         current_app.logger.error(f"Conflict. Consent request {challenge} already used")
         abort(503, description="Consent request already used. Please try again")
 
@@ -258,7 +300,7 @@ def consent():
 
     # Get the related user object
     current_app.logger.info(f"Info: Getting user from admin {kratos_id}")
-    user = KratosUser(KRATOS_ADMIN, kratos_id)
+    user = KratosUser(admin_identity_api, kratos_id)
     if not user:
         current_app.logger.error(f"User not found in database: {kratos_id}")
         abort(401, description="User not found. Please try again.")
@@ -280,12 +322,16 @@ def consent():
             current_app.logger.info(f"{kratos_id} was granted admin access to {client_id}")
             # Get claims for this user, provided the current app
             claims = user.get_claims(None, ['admin'])
+            current_app.logger.info(f"claims: {claims}")
             return redirect(
-                consent_request.accept(
-                    grant_scope=consent_request.requested_scope,
-                    grant_access_token_audience=consent_request.requested_access_token_audience,
-                    session=claims,
-                )
+                hydra_admin_api.accept_consent_request(
+                    challenge,
+                    accept_consent_request=AcceptConsentRequest(
+                        grant_scope=consent_request.requested_scope,
+                        grant_access_token_audience=consent_request.requested_access_token_audience,
+                        session=ConsentRequestSession(**claims),
+                    )
+                ).redirect_to
             )
 
     # Resolve to which app the client_id belongs.
@@ -294,11 +340,15 @@ def consent():
     except AttributeError:
         current_app.logger.error(f"Could not find app for client {client_id}")
         return redirect(
-            consent_request.reject(
-                error="No access",
-                error_description="The user has no access for app",
-                error_hint="Contact your administrator",
-                status_code=401,
+            hydra_admin_api.reject_consent_request(
+                challenge,
+                # In previous versions of the hydra API client library, we
+                # could set these parameters, but that's no longer possible,
+                # not sure why.
+                # error="No access",
+                # error_description="The user has no access for app",
+                # error_hint="Contact your administrator",
+                # status_code=401,
             )
         )
 
@@ -315,11 +365,15 @@ def consent():
         # If there is no role in app_roles or the role_id for an app is null user has no permissions
         current_app.logger.error(f"User has no access for: {app_obj.name}")
         return redirect(
-            consent_request.reject(
-                error="No access",
-                error_description="The user has no access for app",
-                error_hint="Contact your administrator",
-                status_code=401,
+            hydra_admin_api.reject_consent_request(
+                challenge,
+                # In previous versions of the hydra API client library, we
+                # could set these parameters, but that's no longer possible,
+                # not sure why.
+                # error="No access",
+                # error_description="The user has no access for app",
+                # error_hint="Contact your administrator",
+                # status_code=401,
             )
         )
     else:
@@ -337,14 +391,23 @@ def consent():
     current_app.logger.info(f"{kratos_id} was granted access to {client_id}")
 
     # False positive: pylint: disable=no-member
-    return redirect(
-        consent_request.accept(
-            grant_scope=consent_request.requested_scope,
-            grant_access_token_audience=consent_request.requested_access_token_audience,
-            session=claims,
-        )
-    )
+    try:
+        redirectUrl = hydra_admin_api.accept_consent_request(
+            challenge,
+            accept_consent_request=AcceptConsentRequest(
+                grant_scope=consent_request.requested_scope,
+                grant_access_token_audience=consent_request.requested_access_token_audience,
+                session=ConsentRequestSession(**claims),
+            )
+        ).redirect_to
+    except:
+        # If an unexpected error occurs, logout, hopefully that wipes the
+        # relevant cookies
+        current_app.logger.error('Fatal processing consent, redirect to logout:' + str(e))
+        return redirect("logout")
+    current_app.logger.info(f"Redirect to: {redirectUrl}")
 
+    return redirect(redirectUrl)
 
 @web.route("/status", methods=["GET", "POST"])
 def status():
@@ -373,14 +436,14 @@ def get_auth():
 
     # Given a cookie, check if it is valid and get the profile
     try:
-        api_response = KRATOS_PUBLIC.to_session(cookie=cookie)
+        api_response = kratos_public_frontend_api.to_session(cookie=cookie)
 
         # Get all traits from ID
         return api_response.identity
 
     except ory_kratos_client.ApiException as ex:
         current_app.logger.error(
-            f"Exception when calling V0alpha2Api->to_session(): {ex}\n"
+            f"Exception when calling to_session(): {ex}\n"
         )
 
     return False
@@ -425,11 +488,13 @@ def prelogout():
     if not challenge:
         abort(403)
     try:
-        logout_request = HYDRA.logout_request(challenge)
-    except hydra_client.exceptions.NotFound:
+        # hydra v2
+        # logout_request = oauth2_api.get_o_auth2_logout_request(challenge)
+        logout_request = hydra_admin_api.get_logout_request(challenge)
+    except hydra_exceptions.NotFoundException:
         current_app.logger.error("Logout request with challenge '%s' not found", challenge)
         abort(404, "Hydra session invalid or not found")
-    except hydra_client.exceptions.HTTPError:
+    except hydra_exceptions.ApiException:
         current_app.logger.error(
             "Conflict. Logout request with challenge '%s' has been used already.",
             challenge)
@@ -439,9 +504,9 @@ def prelogout():
 
     # Accept logout request and direct to hydra to remove cookies
     try:
-        hydra_return = logout_request.accept(subject=logout_request.subject)
+        hydra_return = hydra_admin_api.accept_logout_request(challenge)
         if hydra_return:
-          return redirect(hydra_return)
+          return redirect(hydra_return.redirect_to)
 
     except Exception as ex:
         current_app.logger.info("Error logging out hydra: %s", str(ex))
@@ -471,12 +536,23 @@ def logout():
     try:
         # Create a Logout URL for Browsers
         kratos_api_response = \
-            KRATOS_ADMIN.create_self_service_logout_flow_url_for_browsers(
+            admin_frontend_api.create_browser_logout_flow(
                 cookie=kratos_cookie)
         current_app.logger.info(kratos_api_response)
     except ory_kratos_client.ApiException as ex:
         current_app.logger.error("Exception when calling"
-            " V0alpha2Api->create_self_service_logout_flow_url_for_browsers: %s\n",
+            " create_self_service_logout_flow_url_for_browsers: %s\n",
             ex)
     return redirect(kratos_api_response.logout_url)
 
+
+if DEMO_INSTANCE:
+    @web.route("/demo-user", methods=["POST"])
+    def demo_user():
+        data = request.get_json()
+        defaults = {
+            "name": "",
+            "app_roles": [{"name": "dashboard", "role_id": Role.ADMIN_ROLE_ID}],
+        }
+        UserService.post_user({**defaults, **data})
+        return jsonify("User created successfully. You should receive an email to confirm your address and set a password.")
diff --git a/backend/web/static/base.js b/backend/web/static/base.js
index 0e142ed6e01a4ccd417899ea44262e60d715e8fc..e7cab4a0d962dec9018d14fe703cb784a1a612da 100644
--- a/backend/web/static/base.js
+++ b/backend/web/static/base.js
@@ -49,12 +49,19 @@ function flow_login() {
     type: 'GET',
     url: uri,
     success: function (data) {
-      // Render login form (group: password)
-      var form_html = render_form(data, 'password');
-      $('#contentLogin').html(form_html);
+      // Determine which groups to show.
+      var groups = scrape_groups(data);
+      for (const group of groups) {
+        // Render login form (group: password)
+        var form_html = render_form(data, group, 'login');
+        $('#contentLogin_' + group).html(form_html);
+      }
 
       var messages_html = render_messages(data);
       $('#contentMessages').html(messages_html);
+
+      $('#totp_code').focus();
+      $('#identifier').focus();
     },
     complete: function (obj) {
       // If we get a 410, the flow is expired, need to refresh the flow
@@ -92,7 +99,7 @@ function flow_settings_validate() {
 
         // For now, this code assumes that only the password can fail
         // validation. Other forms might need to be added in the future.
-        html = render_form(data, 'password');
+        html = render_form(data, 'password', 'validation');
         $('#contentPassword').html(html);
       }
     },
@@ -100,7 +107,8 @@ function flow_settings_validate() {
 }
 
 // Render the settings flow, this is where users can change their personal
-// settings, like name and password. The form contents are defined by Kratos
+// settings, like name, password and totp (second factor). The form contents
+// are defined by Kratos.
 function flow_settings() {
   // Get the details from the current flow from kratos
   var flow = $.urlParam('flow');
@@ -118,20 +126,24 @@ function flow_settings() {
         Cookies.set('flow_state', 'settings');
       }
 
-      // Hide prfile section if we are in recovery state
+      // Hide everything except password section if we are in recovery state,
       // so the user is not confused by other fields. The user
-      // probably want to setup a password only first.
+      // probably wants to setup a password only first.
       if (state == 'recovery') {
         $('#contentProfile').hide();
+        $('#contentTotp').hide();
       }
 
-      // Render the password & profile form based on the fields we got
-      // from the API
-      var html = render_form(data, 'password');
-      $('#contentPassword').html(html);
+      // Render the forms (password, profile, totp) based on the fields we got
+      // from the API.
+      var html = render_form(data, 'password', 'settings');
+      $('#pills-password').html(html);
+
+      html = render_form(data, 'profile', 'settings');
+      $('#pills-profile').html(html);
 
-      html = render_form(data, 'profile');
-      $('#contentProfile').html(html);
+      html = render_form(data, 'totp', 'settings');
+      $('#pills-totp').html(html);
 
       // If the submit button is hit, execute the POST with Ajax.
       $('#formpassword').submit(function (e) {
@@ -171,7 +183,7 @@ function flow_recover() {
     url: uri,
     success: function (data) {
       // Render the recover form, method 'link'
-      var html = render_form(data, 'link');
+      var html = render_form(data, 'link', 'recovery');
       $('#contentRecover').html(html);
 
       // Do form post as an AJAX call
@@ -206,27 +218,36 @@ function flow_recover() {
   });
 }
 
+// Based on Kratos UI data, decide which node groups to process.
+function scrape_groups(data) {
+  var nodes = new Set();
+  for (const node of data.ui.nodes) {
+    if (node.group != 'default') {
+      nodes.add(node.group);
+    }
+  }
+  return nodes;
+}
+
 // Based on Kratos UI data and a group name, get the full form for that group.
 // kratos groups elements which belongs together in a group and should be posted
 // at once. The elements in the default group should be part of all other
 // groups.
 //
 // data: data object as returned form the API
-// group: group to render.
-function render_form(data, group) {
+// group: group to render
+// context: string to specify the context of this form. We need this because
+//   the Kratos UI data is not sufficient in some cases to decide things like
+//   texts and button labels.
+function render_form(data, group, context) {
   // Create form
   var action = data.ui.action;
   var method = data.ui.method;
   var form = "<form id='form" + group + "' method='" + method + "' action='" + action + "'>";
 
   for (const node of data.ui.nodes) {
-    var name = node.attributes.name;
-    var type = node.attributes.type;
-    var value = node.attributes.value;
-    var messages = node.messages;
-
     if (node.group == 'default' || node.group == group) {
-      var elm = getFormElement(type, name, value, messages);
+      var elm = getFormElement(node, context);
       form += elm;
     }
   }
@@ -237,7 +258,7 @@ function render_form(data, group) {
 // Check if there are any general messages to show to the user and render them
 function render_messages(data) {
   var messages = data.ui.messages;
-  if (messages == []) {
+  if (typeof message == 'undefined' || messages == []) {
     return '';
   }
   var html = '<ul>';
@@ -260,8 +281,37 @@ function render_messages(data) {
 // name: name of the field. Used when posting data
 // value: If there is already a value known, show it
 // messages: error messages related to the field
-function getFormElement(type, name, value, messages) {
-  console.log('Getting form element', type, name, value, messages);
+function getFormElement(node, context) {
+  console.log('Getting form element', node);
+
+  if (node.type == 'img') {
+    return (
+      `
+            <img id="` +
+      node.attributes.id +
+      `" src='` +
+      node.attributes.src +
+      `'>`
+    );
+  }
+
+  if (node.type == 'text') {
+    return (
+      `
+            <span id="` +
+      node.attributes.id +
+      `" class="form-display form-display-` +
+      node.attributes.text.type +
+      `">` +
+      node.attributes.text.text +
+      `</span>`
+    );
+  }
+
+  var name = node.attributes.name;
+  var type = node.attributes.type;
+  var value = node.attributes.value;
+  var messages = node.messages;
 
   if (value == undefined) {
     value = '';
@@ -321,7 +371,42 @@ function getFormElement(type, name, value, messages) {
     );
   }
 
+  if (name == 'totp_code') {
+    return getFormInput(
+      'totp_code',
+      name,
+      value,
+      'TOTP code',
+      'Please enter the code from your TOTP/authenticator app.',
+      null,
+      messages,
+    );
+  }
+
   if (type == 'submit') {
+    var label = 'Save';
+    if (name == 'totp_unlink') {
+      label = 'Forget saved TOTP device';
+    }
+    else if (node.group == 'totp') {
+      if (context == 'settings') {
+        label = 'Enroll TOTP device';
+      }
+      else {
+        label = 'Verify';
+      }
+    }
+    if (name == 'method' && value == 'password') {
+      if (context == 'settings') {
+        label = 'Update password';
+      }
+      else {
+        label = 'Log in';
+      }
+    }
+    if (context == 'recovery') {
+      label = 'Send recovery link';
+    }
     return (
       `<div class="form-group">
             <input type="hidden" name="` +
@@ -329,7 +414,7 @@ function getFormElement(type, name, value, messages) {
       `" value="` +
       value +
       `">
-             <button type="submit" class="btn btn-primary">Go!</button>
+             <button type="submit" class="btn btn-primary">` + label + `</button>
             </div>`
     );
   }
diff --git a/backend/web/static/style.css b/backend/web/static/style.css
index c563eb2b4a8cf1cd10f5a651a61215e31256f64e..6e4b74422493dd012741e897486e988641498bf7 100644
--- a/backend/web/static/style.css
+++ b/backend/web/static/style.css
@@ -1,5 +1,3 @@
-
-
 div.loginpanel {
     width: 644px;
     margin-left: auto;
@@ -10,3 +8,25 @@ div.loginpanel {
 button {
     margin-top: 10px;
 }
+
+.form-display {
+    font-family: monospace;
+    display: block;
+    width: 100%;
+    padding: .375rem .75rem;
+    font-size: 1rem;
+    line-height: 1.5;
+    color: #495057;
+    background-color: #fff;
+    background-clip: padding-box;
+    border: 1px solid #ced4da;
+    border-radius: .25rem;
+}
+
+#pills-tab {
+    margin-bottom: 1rem;
+}
+
+#totp_qr {
+    padding: 2rem;
+}
diff --git a/backend/web/templates/base.html b/backend/web/templates/base.html
index 9d5765f1fa1116554095827bf469a24fde7cf26a..081453028d4f0b389f77dbac03dbd0056cc2cb1b 100644
--- a/backend/web/templates/base.html
+++ b/backend/web/templates/base.html
@@ -2,8 +2,8 @@
 <html>
     <link rel="stylesheet" href="static/css/bootstrap.min.css">
     <link rel="stylesheet" href="static/style.css">
-    <script src="static/js/bootstrap.bundle.min.js"></script>
     <script src="static/js/jquery-3.6.0.min.js"></script>
+    <script src="static/js/bootstrap.bundle.min.js"></script>
     <script src="static/js/js.cookie.min.js"></script>
     <script src="static/base.js"></script>
     <title>Stackspin Account</title>
@@ -32,7 +32,7 @@
         style='display:none'>Your request is expired. Please resubmit your request faster.</div>
 
 
-<img src='static/logo.svg'/><br/><br/>
+<a href="{{ dashboard_url }}"><img src='static/logo.svg'/></a><br/><br/>
 
 {% block content %}{% endblock %}
 
diff --git a/backend/web/templates/login.html b/backend/web/templates/login.html
index 844eef72ff644be2ae5fcebed74eb3d89016dbe6..11d9f8a5315dfb97662604ad906c3022e2729971 100644
--- a/backend/web/templates/login.html
+++ b/backend/web/templates/login.html
@@ -15,11 +15,72 @@
 </script>
 
 
-
+{% if refresh %}
+    <div class="alert alert-warning">Please confirm your credentials to complete this action.</div>
+{% endif %}
     <div id="contentMessages"></div>
-    <div id="contentLogin"></div>
+    <div id="contentLogin_password"></div>
+    <div id="contentLogin_totp"></div>
     <div id="contentHelp">
         <a href='recovery'>Set new password</a> | <a href='https://stackspin.net'>About stackspin</a>
     </div>
+{% if demo %}
+  <br>
+  <script>
+    function submitSignup() {
+      let result = document.querySelector('#signup-result');
+      let email = document.querySelector('#signup-email');
+      let xhr = new XMLHttpRequest();
+      xhr.responseType = 'json';
+      let url = "/web/demo-user";
+      xhr.open("POST", url, true);
+      xhr.setRequestHeader("Content-Type", "application/json");
+      xhr.onreadystatechange = function () {
+        if (xhr.readyState === 4) {
+          // In the success case, we get a plain (json) string; in the error
+          // case, we get an object with `errorMessage` property.
+          if (typeof(this.response) == 'object' && 'errorMessage' in this.response) {
+            window.console.log("Error in sign-up request.");
+            result.classList.remove('alert-success');
+            result.classList.add('alert-danger');
+            result.innerHTML = this.response.errorMessage;
+          } else {
+            result.classList.add('alert-success');
+            result.classList.remove('alert-danger');
+            result.innerHTML = this.response;
+          }
+          result.style.visibility = 'visible';
+        }
+      };
+      // Converting JSON data to string
+      var data = JSON.stringify({"email": email.value });
+      // Sending data with the request
+      xhr.send(data);
+      }
+  </script>
+  <h4>Sign up for this demo instance</h4>
+  Enter your email address here to create an account on this Stackspin
+  instance.
+  <div class="alert alert-warning" style="margin-top: 1em;">
+    Warning: this is a demo instance! That means that:
+    <ul>
+      <li>Anyone can create an account on this same instance, like yourself,
+        and will share the same set of users and data. So any data you create
+        or upload, including the email address you enter here, becomes
+        essentially public information.</li>
+      <li>Every night (Europe/Amsterdam time), this instance gets automatically
+        reset to an empty state, so any data you create or upload will be
+        destroyed.</li>
+    </ul>
+  </div>
+  <div class="form-group">
+    <label for="signup-email">Email address</label>
+    <input type="email" class="form-control" id="signup-email" name="signup-email" placeholder="Your email address to sign up with.">
+  </div>
+  <div class="form-group">
+    <button class="btn btn-primary" onclick="submitSignup()">Sign up</button>
+    <div id="signup-result" class="alert" style="visibility: hidden; margin-top: 1em;"></div>
+  </div>
+{% endif %}
 
 {% endblock %}
diff --git a/backend/web/templates/settings.html b/backend/web/templates/settings.html
index 8cb290afa9f0d5e09b34177b1e0ec74529e2b2cd..1722e08446e722dbcfaf25dbf99366a1deaa6b5d 100644
--- a/backend/web/templates/settings.html
+++ b/backend/web/templates/settings.html
@@ -15,7 +15,6 @@
 </script>
 
 
-
     <div id="contentMessages"></div>
     <div id="contentProfileSaved" 
         class='alert alert-success' 
@@ -23,8 +22,20 @@
     <div id="contentProfileSaveFailed" 
         class='alert alert-danger' 
         style='display:none'>Your changes are not saved. Please check the fields for errors.</div>
+
+<div class="nav nav-pills" id="pills-tab" role="tablist">
+  <a class="nav-link active" id="pills-home-tab" data-toggle="pill" href="#pills-profile" role="tab" aria-controls="pills-profile" aria-selected="true">Profile</a>
+  <a class="nav-link" id="pills-password-tab" data-toggle="pill" href="#pills-password" role="tab" aria-controls="pills-password" aria-selected="false">Change password</a>
+  <a class="nav-link" id="pills-totp-tab" data-toggle="pill" href="#pills-totp" role="tab" aria-controls="pills-totp" aria-selected="false">Second factor authentication</a>
+</div>
+<div class="tab-content" id="pills-tabContent">
+  <div class="tab-pane fade show active" id="pills-profile" role="tabpanel" aria-labelledby="pills-profile-tab">...</div>
+  <div class="tab-pane fade" id="pills-password" role="tabpanel" aria-labelledby="pills-password-tab">...</div>
+  <div class="tab-pane fade" id="pills-totp" role="tabpanel" aria-labelledby="pills-totp-tab">...</div>
+</div>
     <div id="contentProfile"></div>
     <div id="contentPassword"></div>
+    <div id="contentTotp"></div>
 
 
 {% endblock %}
diff --git a/deployment/helmchart/CHANGELOG.md b/deployment/helmchart/CHANGELOG.md
index 2d56fd2de6e23b426de12f4ee8e68fe8a4aa0c2f..ea0d36a7adbe91918aa65af0d870359b582fe75a 100644
--- a/deployment/helmchart/CHANGELOG.md
+++ b/deployment/helmchart/CHANGELOG.md
@@ -1,5 +1,13 @@
 # Changelog
 
+## [1.6.2]
+
+* Update dashboard to version 0.6.2
+
+## [1.6.1]
+
+* Update dashboard to version 0.6.1
+
 ## [1.5.2]
 
 * Update dashboard to version 0.5.2
diff --git a/deployment/helmchart/Chart.yaml b/deployment/helmchart/Chart.yaml
index b9c9d501158ed7769000f523dd20e090af7e3d75..62fef710abc91f367d240266818e2ccff64d4c55 100644
--- a/deployment/helmchart/Chart.yaml
+++ b/deployment/helmchart/Chart.yaml
@@ -1,7 +1,7 @@
 annotations:
   category: Dashboard
 apiVersion: v2
-appVersion: 0.5.2
+appVersion: 0.6.2
 dependencies:
   - name: common
     # https://artifacthub.io/packages/helm/bitnami/common
diff --git a/deployment/helmchart/templates/configmaps.yaml b/deployment/helmchart/templates/configmaps.yaml
index 8221461748db6101c286b75da03ca53a203ee0cc..679c700471025158185d80acd67c818b7bbc8746 100644
--- a/deployment/helmchart/templates/configmaps.yaml
+++ b/deployment/helmchart/templates/configmaps.yaml
@@ -19,10 +19,9 @@ data:
   KRATOS_PUBLIC_URL: {{ .Values.backend.kratos.publicUrl }}
   KRATOS_ADMIN_URL: {{ .Values.backend.kratos.adminUrl }}
   HYDRA_PUBLIC_URL: {{ .Values.backend.oidc.baseUrl }}
-  # React can only read this env variable if it's prepended with REACT_APP
-  REACT_APP_HYDRA_PUBLIC_URL: {{ .Values.backend.oidc.baseUrl }}
   HYDRA_ADMIN_URL: {{ .Values.backend.hydra.adminUrl }}
-  LOGIN_PANEL_URL: {{ .Values.backend.loginPanelUrl }}
+  DASHBOARD_URL: {{ .Values.backend.dashboardUrl }}
+  LOGIN_PANEL_URL: {{ .Values.backend.dashboardUrl }}/web
   DATABASE_URL: {{ .Values.backend.databaseUrl }}
   LOAD_INCLUSTER_CONFIG: "true"
   # {{- if .Values.backend.smtp.enabled }}
diff --git a/deployment/helmchart/values-local.yaml.example b/deployment/helmchart/values-local.yaml.example
index 2ab9d69006817d03e5d25e2e15b29cd40127598a..aba3ce4b92fb75e19a03066711d6475a67559195 100644
--- a/deployment/helmchart/values-local.yaml.example
+++ b/deployment/helmchart/values-local.yaml.example
@@ -17,8 +17,8 @@ backend:
   kratos:
     publicUrl: https://sso.stackspin.example.org/kratos
 
-  # Public URL of login panel
-  loginPanelUrl: https://dashboard.stackspin.example.org/web/
+  # Public URL of dashboard
+  dashboardUrl: https://dashboard.stackspin.example.org
 
   # Database connection
   # databaseUrl: mysql+pymysql://stackspin:password@single-sign-on-database-mariadb/stackspin
diff --git a/deployment/helmchart/values.yaml b/deployment/helmchart/values.yaml
index 521f3925b48d4c9902c15019392b2cb2fb45f276..343d5e54976054598bd4a4e85c7c8b342118f84c 100644
--- a/deployment/helmchart/values.yaml
+++ b/deployment/helmchart/values.yaml
@@ -68,7 +68,7 @@ dashboard:
   image:
     registry: open.greenhost.net:4567
     repository: stackspin/dashboard/dashboard
-    tag: 0.6.0
+    tag: 0.6.2
     digest: ""
     ## Optionally specify an array of imagePullSecrets.
     ## Secrets must be manually created in the namespace.
@@ -263,8 +263,8 @@ backend:
   hydra:
     adminUrl: http://hydra-admin:4445
 
-  # Public URL of login panel
-  loginPanelUrl: https://dashboard.stackspin.example.org/web/
+  # Public URL of dashboard
+  dashboardUrl: https://dashboard.stackspin.example.org
   databaseUrl: mysql+pymysql://stackspin:stackspin@single-sign-on-database-mariadb/stackspin
 
   initialUser:
diff --git a/docker-compose.yml b/docker-compose.yml
index fe11ab8c1886ece8f46929221c59dbdeab4217f9..655be3e58c31e6d70fefdab2b1f40398e6fee636 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,11 +3,12 @@ services:
   frontend:
     build:
       context: ./frontend
-    working_dir: "/home/node/app"
     env_file: ./frontend/local.env
+    volumes:
+      - ./frontend/src:/home/node/app/src
     ports:
       - "3000:3000"
-    # command: "yarn start"
+    command: "yarn run start"
   stackspin_proxy:
     image: nginx:1.23.3
     ports:
@@ -31,6 +32,7 @@ services:
       - HYDRA_PUBLIC_URL=https://sso.$DOMAIN
 
       # Local path overrides
+      - DASHBOARD_URL=http://localhost:3000
       - KRATOS_PUBLIC_URL=http://stackspin_proxy:8081/kratos
       - KRATOS_ADMIN_URL=http://kube_port_kratos_admin:8000
       - HYDRA_ADMIN_URL=http://kube_port_hydra_admin:4445
@@ -54,7 +56,7 @@ services:
       - kube_port_mysql
     entrypoint: ["bash", "-c", "flask run --host $$(hostname -i)"]
   kube_port_kratos_admin:
-    image: bitnami/kubectl:1.26.1
+    image: bitnami/kubectl:1.26.2
     user: "${KUBECTL_UID}:${KUBECTL_GID}"
     expose:
       - 8000
@@ -62,7 +64,7 @@ services:
       - "$KUBECONFIG:/.kube/config"
     entrypoint: ["bash", "-c", "kubectl -n stackspin port-forward --address $$(hostname -i) service/kratos-admin 8000:80"]
   kube_port_hydra_admin:
-    image: bitnami/kubectl:1.26.1
+    image: bitnami/kubectl:1.26.2
     user: "${KUBECTL_UID}:${KUBECTL_GID}"
     expose:
       - 4445
@@ -70,7 +72,7 @@ services:
       - "$KUBECONFIG:/.kube/config"
     entrypoint: ["bash", "-c", "kubectl -n stackspin port-forward --address $$(hostname -i) service/hydra-admin 4445:4445"]
   kube_port_kratos_public:
-    image: bitnami/kubectl:1.26.1
+    image: bitnami/kubectl:1.26.2
     user: "${KUBECTL_UID}:${KUBECTL_GID}"
     ports:
       - "8080:8080"
@@ -80,7 +82,7 @@ services:
       - "$KUBECONFIG:/.kube/config"
     entrypoint: ["bash", "-c", "kubectl -n stackspin port-forward --address 0.0.0.0 service/kratos-public 8080:80"]
   kube_port_mysql:
-    image: bitnami/kubectl:1.26.1
+    image: bitnami/kubectl:1.26.2
     user: "${KUBECTL_UID}:${KUBECTL_GID}"
     expose:
       - 3306
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index ee080ade68a7b99b3ffb7cdb19a715f1bb21970c..6a7e219519bfc587a3d30f0548cb142c43ca94fc 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -1,8 +1,15 @@
 FROM node:18
 
-ADD . .
+WORKDIR /home/node/app
+
+# First copy only files necessary for installing dependencies, so that we can
+# cache that step even when our own source code changes.
+COPY package.json yarn.lock .
 
 RUN yarn install
 
+# Now copy the rest of the source.
+COPY . .
+
 ENV NODE_OPTIONS="--openssl-legacy-provider"
 CMD yarn start
diff --git a/frontend/local.env.example b/frontend/local.env.example
index ce5f17913124ad997e6af938e3ac9a0e5bc6a56d..c32c7c4c8823ca6cc797fa21257da451eacfdd22 100644
--- a/frontend/local.env.example
+++ b/frontend/local.env.example
@@ -1,2 +1 @@
 REACT_APP_API_URL=http://stackspin_proxy:8081/api/v1
-REACT_APP_HYDRA_PUBLIC_URL=https://sso.init.stackspin.net
diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx
index 0c783e1597290ed72404d259fe90b04d0ac4f62f..4d57a9aec1305b3a781976665d6dd3d13284a83e 100644
--- a/frontend/src/components/Header/Header.tsx
+++ b/frontend/src/components/Header/Header.tsx
@@ -1,6 +1,7 @@
-import React, { Fragment, useMemo, useState } from 'react';
+import React, { Fragment, useEffect, useState } from 'react';
 import { Disclosure, Menu, Transition } from '@headlessui/react';
 import { MenuIcon, XIcon } from '@heroicons/react/outline';
+import { performApiCall } from 'src/services/api';
 import { useAuth } from 'src/services/auth';
 import Gravatar from 'react-gravatar';
 import { Link, useLocation } from 'react-router-dom';
@@ -9,8 +10,6 @@ import _ from 'lodash';
 
 import { UserModal } from '../UserModal';
 
-const HYDRA_LOGOUT_URL = `${process.env.REACT_APP_HYDRA_PUBLIC_URL}/oauth2/sessions/logout`;
-
 const navigation = [
   { name: 'Dashboard', to: '/dashboard', requiresAdmin: false },
   { name: 'Users', to: '/users', requiresAdmin: true },
@@ -29,16 +28,44 @@ function filterNavigationByDashboardRole(isAdmin: boolean) {
   return navigation.filter((item) => !item.requiresAdmin);
 }
 
+export interface Environment {
+  HYDRA_PUBLIC_URL: string;
+  KRATOS_PUBLIC_URL: string;
+}
+
+const defaultEnvironment: Environment = {
+  HYDRA_PUBLIC_URL: 'error-failed-to-load-env-from-backend',
+  KRATOS_PUBLIC_URL: 'error-failed-to-load-env-from-backend',
+};
+
 // eslint-disable-next-line @typescript-eslint/no-empty-interface
 interface HeaderProps {}
 
 const Header: React.FC<HeaderProps> = () => {
+  const [environment, setEnvironment] = useState(defaultEnvironment);
   const [currentUserModal, setCurrentUserModal] = useState(false);
   const [currentUserId, setCurrentUserId] = useState(null);
   const { logOut, currentUser, isAdmin } = useAuth();
 
   const { pathname } = useLocation();
 
+  useEffect(() => {
+    let active = true;
+    async function loadEnvironment() {
+      const result = await performApiCall({
+        path: '/environment',
+      });
+      if (!active) {
+        return;
+      }
+      setEnvironment(result.data);
+    }
+    loadEnvironment();
+    return () => {
+      active = false;
+    };
+  }, []);
+
   const currentUserModalOpen = (id: any) => {
     setCurrentUserId(id);
     setCurrentUserModal(true);
@@ -51,14 +78,8 @@ const Header: React.FC<HeaderProps> = () => {
 
   const navigationItems = filterNavigationByDashboardRole(isAdmin);
 
-  const signOutUrl = useMemo(() => {
-    const { hostname } = window.location;
-    // If we are developing locally, we need to use the init cluster's public URL
-    if (hostname === 'localhost') {
-      return HYDRA_LOGOUT_URL;
-    }
-    return `https://${hostname.replace(/^dashboard/, 'sso')}/oauth2/sessions/logout`;
-  }, []);
+  const signOutUrl = `${environment.HYDRA_PUBLIC_URL}/oauth2/sessions/logout`;
+  const kratosSettingsUrl = `${environment.KRATOS_PUBLIC_URL}/self-service/settings/browser`;
 
   return (
     <>
@@ -136,6 +157,19 @@ const Header: React.FC<HeaderProps> = () => {
                             </a>
                           )}
                         </Menu.Item>
+                        <Menu.Item>
+                          {({ active }) => (
+                            <a
+                              href={kratosSettingsUrl}
+                              className={classNames(
+                                active ? 'bg-gray-100 cursor-pointer' : '',
+                                'block px-4 py-2 text-sm text-gray-700 cursor-pointer',
+                              )}
+                            >
+                              Authentication settings
+                            </a>
+                          )}
+                        </Menu.Item>
                         <Menu.Item>
                           {({ active }) => (
                             <a
diff --git a/frontend/src/components/UserModal/UserModal.tsx b/frontend/src/components/UserModal/UserModal.tsx
index 29e9738db998c6f1516d9e29357ba726d621a847..5e64122718e2f356ee8f7837e9ee6c525c896819 100644
--- a/frontend/src/components/UserModal/UserModal.tsx
+++ b/frontend/src/components/UserModal/UserModal.tsx
@@ -197,6 +197,11 @@ export const UserModal = ({ open, onClose, userId, setUserId }: UserModalProps)
                         </select>
                       </div>
                     </div>
+                    {userId && (
+                      <div className="sm:col-span-6">
+                        <Input control={control} name="id" label="UUID" required={false} disabled />
+                      </div>
+                    )}
                   </>
                 )}
               </div>
diff --git a/renovate.json b/renovate.json
index 70fbf6501a77c71f7f5faa476bbc18c566fc08fc..3644c8c9361cc137031cbd352145bb7f4dade9be 100644
--- a/renovate.json
+++ b/renovate.json
@@ -5,5 +5,12 @@
     ],
     "npm": {
         "enabled": false
-    }
+    },
+    "packageRules": [
+        {
+            "matchDepNames": ["node"],
+            "matchFiles": ["frontend/Dockerfile", ".gitlab-ci.yml"],
+            "allowedVersions": "!/^\d*[13579](-.*)?$/"
+        }
+    ]
 }
diff --git a/run_app.sh b/run_app.sh
index b4d203d9e219474aa2a57b229d1855e501485dd2..26e94e18936b32d7a93103ed8586bc24b0707900 100755
--- a/run_app.sh
+++ b/run_app.sh
@@ -2,6 +2,8 @@
 
 set -euo pipefail
 
+dockerComposeArgs=$@
+
 export DATABASE_PASSWORD=$(kubectl get secret -n flux-system stackspin-single-sign-on-variables -o jsonpath --template '{.data.dashboard_database_password}' | base64 -d)
 export DOMAIN=$(kubectl get secret -n flux-system stackspin-cluster-variables -o jsonpath --template '{.data.domain}' | base64 -d)
 export HYDRA_CLIENT_SECRET=$(kubectl get secret -n flux-system stackspin-dashboard-local-oauth-variables -o jsonpath --template '{.data.client_secret}' | base64 -d)
@@ -29,4 +31,4 @@ if [[ -z "$HYDRA_CLIENT_SECRET" ]]; then
     exit 1
 fi
 
-KUBECTL_UID=${UID:-1001} KUBECTL_GID=${GID:-0} docker compose up
+KUBECTL_UID=${UID:-1001} KUBECTL_GID=${GID:-0} docker compose up $dockerComposeArgs