Repository: canonical/juju-dashboard
Pull request #1942: chore(ci): add JIMM workflow
Author: login: huwshimi
Labels: name: Review: Code +1 (with changes)
name: e2e
description: Run E2E tests on this PR.
Created: 2025-04-22T06:41:24Z | Merged: 2025-04-30T04:20:00Z

## Done

- Add a workflow for JIMM.

## Details

https://warthogs.atlassian.net/browse/WD-20941



Diff:
diff --git a/.github/actions/run-playwright/action.yml b/.github/actions/run-playwright/action.yml
index 592316ec9..19a89a51b 100644
--- a/.github/actions/run-playwright/action.yml
+++ b/.github/actions/run-playwright/action.yml
@@ -4,32 +4,45 @@ inputs:
   test-identifier:
     description: Unique identifier for this test run that will be used for uploading test results.
     required: true
+  run:
+    default: 'true'
+    description: Whether to run the test suite.
+    required: false
+  setup:
+    default: 'true'
+    description: Whether to set up Playwright.
+    required: false
 
 runs:
   using: "composite"
   steps:
     - name: Use Node.js ${{ matrix.node-version }}
+      if: ${{ inputs.setup == 'true' }}
       uses: actions/setup-node@v4
       with:
         node-version: ${{ matrix.node-version }}
         cache: "yarn"
     - name: Install dependencies
+      if: ${{ inputs.setup == 'true' }}
       shell: bash
       run: yarn install
     - name: Install Playwright Browsers
+      if: ${{ inputs.setup == 'true' }}
       run: yarn playwright install --with-deps
       shell: bash
     - name: Get dashboard address
+      if: ${{ inputs.run == 'true' }}
       id: dashboard
       run: echo "address=http://$(address=$(juju show-unit dashboard/0 | yq '.dashboard/0.public-address'); if [ "$address" != "null" ]; then echo $address; else juju show-unit dashboard/0 | yq '.dashboard/0.address'; fi):8080" >> $GITHUB_OUTPUT
       shell: bash
     - name: Run Playwright tests
+      if: ${{ inputs.run == 'true' }}
       run: yarn playwright test
       shell: bash
       env: 
         DASHBOARD_ADDRESS: ${{ steps.dashboard.outputs.address }}
     - uses: actions/upload-artifact@v4
-      if: ${{ !cancelled() }}
+      if: ${{ !cancelled() && inputs.run == 'true' }}
       with:
         name: ${{ inputs.test-identifier }}
         path: test-results/
diff --git a/.github/actions/setup-jimm/action.yml b/.github/actions/setup-jimm/action.yml
new file mode 100644
index 000000000..3d58ebe49
--- /dev/null
+++ b/.github/actions/setup-jimm/action.yml
@@ -0,0 +1,160 @@
+name: Set up JIMM
+description: Set up JIMM with provided configuration.
+inputs:
+  admin-username:
+    required: false
+    description: The username to use when logging in as the admin user.
+    default: test@example.com
+  admin-password:
+    required: false
+    description: The password to use when logging in as the admin user.
+    default: test
+  jimm-channel:
+    description: The JIMM charm channel to use.
+    required: false
+    default: 3/edge
+  juju-channel:
+    description: Channel of Juju to use.
+    required: false
+    default: 3/stable
+outputs:
+  controller-name:
+    description: Name of the JIMM workloads controller
+    value: ${{ steps.workloads-controller.outputs.name }}
+
+runs:
+  using: composite
+  steps:
+    - name: Disable IPv6
+      shell: bash
+      run: |
+        echo "net.ipv6.conf.all.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.default.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.lo.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        sudo sysctl -p
+    - name: Setup operator environment
+      uses: charmed-kubernetes/actions-operator@main
+      with:
+        channel: 1.32-strict/stable
+        juju-channel: ${{ inputs.juju-channel }}
+        provider: microk8s
+        microk8s-group: snap_microk8s
+        microk8s-addons: "hostpath-storage dns ingress host-access metallb:10.64.140.43-10.64.140.49"
+    - name: Set up deps
+      shell: bash
+      run: |
+        sudo snap install yq vault
+        sudo snap install --channel=3/edge jimmctl
+    - name: Setup Go
+      uses: actions/setup-go@v4
+    - name: Set up IAM
+      shell: bash
+      run: |
+        juju add-model iam
+        juju deploy identity-platform --trust --channel latest/edge
+        # Wait for everything to be ready except kratos-external-idp-integrator
+        # which will remain as blocked as we're not using an external identity provider.
+        juju wait-for model iam --query='forEach(applications, app => (app.name == "kratos-external-idp-integrator" && app.status=="blocked") || (app.name != "kratos-external-idp-integrator" && app.status=="active"))' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju offer hydra:oauth
+        juju offer self-signed-certificates:send-ca-cert
+    - name: Create admin user
+      shell: bash
+      run: |
+        juju config kratos enforce_mfa=False
+        juju wait-for unit kratos/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        IDENTITY_ID=$(juju run --format=json kratos/0 create-admin-account email='${{ inputs.admin-username }}' password='${{ inputs.admin-password }}' username='${{ inputs.admin-username }}' | yq .kratos/0.results.identity-id)
+        SECRET_ID=$(juju add-secret password-secret password=test)
+        juju grant-secret password-secret kratos
+        juju run kratos/0 reset-password identity-id=$IDENTITY_ID password-secret-id=$SECRET_ID
+    - name: Set up JIMM
+      shell: bash
+      run: |
+        juju add-model jimm
+        juju deploy juju-jimm-k8s --channel=${{ inputs.jimm-channel }} jimm
+        juju deploy openfga-k8s --channel=edge openfga
+        juju deploy postgresql-k8s --channel=14/stable postgresql
+        juju deploy vault-k8s --channel=1.15/beta vault
+        juju deploy nginx-ingress-integrator --channel=latest/stable --trust ingress
+        juju trust postgresql --scope=cluster
+        # Wait for postgres to prevent issues when relating to openfga: https://github.com/canonical/openfga-operator/issues/25.
+        juju wait-for unit postgresql/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        juju relate jimm:nginx-route ingress
+        juju relate jimm:openfga openfga
+        juju relate jimm:database postgresql
+        juju relate jimm:vault vault
+        juju relate openfga:database postgresql
+        juju wait-for application openfga --query='name=="openfga" && (status=="active" || status=="error")' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju resolved openfga/0 # Sometimes the openfga unit will get into an error state, so resolve and try again.
+        juju wait-for unit openfga/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        juju relate jimm admin/iam.hydra
+        juju relate jimm admin/iam.self-signed-certificates
+        juju deploy self-signed-certificates jimm-cert
+        juju relate ingress:certificates jimm-cert:certificates
+    - name: Set up Vault
+      shell: bash
+      run: |
+        export VAULT_ADDR=https://$(juju status vault/leader --format=yaml | yq '.applications.vault.address'):8200; echo "Vault address =" "$VAULT_ADDR"
+        cert_juju_secret_id=$(juju secrets --format=yaml | yq 'to_entries | .[] | select(.value.label == "self-signed-vault-ca-certificate") | .key'); echo "Vault ca-cert secret ID =" "$cert_juju_secret_id"
+        juju show-secret ${cert_juju_secret_id} --reveal --format=yaml | yq '.[].content.certificate' > vault.pem && echo "saved certificate contents to vault.pem"
+        export VAULT_CAPATH=$(pwd)/vault.pem; echo "Setting VAULT_CAPATH from" "$VAULT_CAPATH"
+        key_init=$(vault operator init -key-shares=1 -key-threshold=1); echo "$key_init"
+        export VAULT_TOKEN=$(echo "$key_init" | sed -n -e 's/.*Root Token: //p'); echo "RootToken = $VAULT_TOKEN"
+        export UNSEAL_KEY=$(echo "$key_init" | sed -n -e 's/.*Unseal Key 1: //p'); echo "UnsealKey = $UNSEAL_KEY"
+        vault operator unseal "$UNSEAL_KEY"
+        vault_secret_id=$(juju add-secret vault-token token="$VAULT_TOKEN")
+        juju grant-secret vault-token vault
+        juju run vault/leader authorize-charm secret-id="$vault_secret_id"
+        juju remove-secret "vault-token"
+    - name: Configure JIMM
+      shell: bash
+      run: |
+        # Give the user admin permissions so that they can add the workload controller.
+        juju config jimm controller-admins="${{ inputs.admin-username }}"
+        juju config jimm uuid=3f4d142b-732e-4e99-80e7-5899b7e67e59
+        juju config jimm dns-name=test-jimm.local
+        KEYS=$(go run github.com/go-macaroon-bakery/macaroon-bakery/cmd/bakery-keygen/v3@latest)
+        juju config jimm public-key=$(echo $KEYS | yq .public)
+        juju config jimm private-key=$(echo $KEYS | yq .private)
+        juju wait-for application ingress --query='name=="ingress" && (status=="active" || status=="error")' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju resolved ingress/0 # Sometimes the ingress unit will get into an error state, so resolve and try again.
+        juju wait-for unit ingress/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        # Point the test-jimm.local hostname to the ingress address.
+        echo "$(juju status ingress/0 --format=json | yq '.applications.ingress.units.ingress/0.workload-status.message | sub("Ingress IP\\(s\\): ", "")') test-jimm.local" | sudo tee -a /etc/hosts
+        juju run jimm-cert/0 get-ca-certificate --quiet | yq .ca-certificate | sudo tee /usr/local/share/ca-certificates/jimm-test.crt
+        sudo update-ca-certificates --fresh
+    - name: Bootstrap workloads controller
+      id: workloads-controller
+      shell: bash
+      run: |
+        # Wait for JIMM to be ready if it's still making config changes.
+        juju wait-for unit jimm/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        WORKLOADS_NAME=workload-microk8s
+        sg snap_microk8s -c "juju bootstrap microk8s $WORKLOADS_NAME --config login-token-refresh-url=http://jimm-endpoints.jimm.svc.cluster.local:8080/.well-known/jwks.json"
+        echo "name=$WORKLOADS_NAME" >> $GITHUB_OUTPUT
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v4
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: "yarn"
+    - name: Set up Playwright
+      uses: ./.github/actions/run-playwright
+      with:
+        run: false
+    - name: Log in to JIMM with Juju CLI
+      shell: bash
+      run: npx tsx .github/actions/setup-jimm/cli-login.ts
+      env:
+        USERNAME: ${{ inputs.admin-username }}
+        PASSWORD: ${{ inputs.admin-password }}
+    - name: Register workload controller with JIMM
+      shell: bash
+      run: |
+        jimmctl controller-info '${{ steps.workloads-controller.outputs.name }}' ~/snap/jimmctl/common/k8s-controller-info.yaml --local --tls-hostname juju-apiserver
+        jimmctl add-controller ~/snap/jimmctl/common/k8s-controller-info.yaml
+        juju update-credentials microk8s --controller jimm-k8s
+    - name: Restart JIMM
+      shell: bash
+      run: |
+        # Fix ListModels issue:
+        sudo microk8s kubectl delete pod jimm-0 -n jimm
+        juju wait-for unit jimm/0 || true # continue if this gets stuck because it stops getting status updates and times out
diff --git a/.github/actions/setup-jimm/cli-login.ts b/.github/actions/setup-jimm/cli-login.ts
new file mode 100644
index 000000000..6f8a9b51d
--- /dev/null
+++ b/.github/actions/setup-jimm/cli-login.ts
@@ -0,0 +1,15 @@
+import { OIDC } from "../../../e2e/helpers/user/oidc";
+
+const username = process.env.USERNAME;
+const password = process.env.PASSWORD;
+if (!username || !password) {
+  throw new Error(
+    `Did not receive all environment variables, got username: "${username}", password: "${password}".`,
+  );
+}
+console.log("Logging in to JIMM.");
+await OIDC.loginCli({
+  username,
+  password,
+});
+console.log("Logged in.");
diff --git a/.github/actions/setup-k8s-charm/action.yml b/.github/actions/setup-k8s-charm/action.yml
index f332a3ae8..a5fb3adc1 100644
--- a/.github/actions/setup-k8s-charm/action.yml
+++ b/.github/actions/setup-k8s-charm/action.yml
@@ -1,6 +1,14 @@
 name: Set up Juju Dashboard k8s charm
 description: Set up the Juju Dashboard k8s charm using the supplied versions.
 inputs:
+  controller-app:
+    required: false
+    description: The Juju controller application.
+    default: controller
+  controller-model:
+    required: false
+    description: The model containing the Juju controller.
+    default: controller
   dashboard-ref:
     description: Git reference to build dashboard from. Cannot be used with `dashboard-resource` or `charm-channel`.
     required: false
@@ -49,19 +57,12 @@ runs:
         ref: ${{ inputs.dashboard-ref }}
         path: juju-dashboard-release
     - name: Build Juju Dashboard image
-      id: build-dashboard-image
       if: ${{ env.build_type == 'checkout' }}
       shell: bash
       run: |
         cd '${{ github.workspace }}/juju-dashboard-release'
-        DOCKER_BUILDKIT=1 docker build -t juju-dashboard .
-        docker image save juju-dashboard | sudo microk8s ctr image import -
-        echo "id=$(docker image inspect juju-dashboard --format '{{.ID}}')" >> "$GITHUB_OUTPUT"
-    - name: Set Juju Dashboard image
-      id: set-dashboard-image
-      if: ${{ env.build_type == 'dashboard-resource' }}
-      shell: bash
-      run: echo "id=canonicalwebteam/juju-dashboard:${{ inputs.dashboard-resource }}" >> "$GITHUB_OUTPUT"
+        DOCKER_BUILDKIT=1 docker build -t juju-dashboard:local .
+        docker image save juju-dashboard:local | sudo microk8s ctr image import -
     - name: Choose dashboard image
       id: dashboard-image
       shell: bash
@@ -69,10 +70,10 @@ runs:
       run: |
         case '${{ env.build_type }}' in
           'checkout')
-            ID='${{ steps.build-dashboard-image.outputs.id }}'
+            ID='juju-dashboard:local'
           ;;
           'dashboard-resource')
-            ID='${{ steps.set-dashboard-image.outputs.id }}'
+            ID='canonicalwebteam/juju-dashboard:${{ inputs.dashboard-resource }}'
           ;;
         esac
         echo "id=$ID" >> "$GITHUB_OUTPUT"
@@ -92,7 +93,7 @@ runs:
         charmcraft pack
       shell: bash
     - name: Switch to controller
-      run: juju switch controller
+      run: juju switch '${{ inputs.controller-model }}'
       shell: bash
     - name: Deploy charm from channel
       if: ${{ env.build_type == 'charm-release' }}
@@ -103,7 +104,7 @@ runs:
       run: juju deploy ${{ github.workspace }}/juju-dashboard-charm/k8s-charm/juju-dashboard*.charm dashboard --resource 'dashboard-image=${{ steps.dashboard-image.outputs.id }}'
       shell: bash
     - name: Integrate charm
-      run: juju integrate controller dashboard
+      run: juju integrate '${{ inputs.controller-app }}' dashboard
       shell: bash
     - name: Wait for charm to be ready
       run: juju wait-for application dashboard
diff --git a/.github/actions/setup-machine-charm/action.yml b/.github/actions/setup-machine-charm/action.yml
index ea1a2a256..6d428c2ec 100644
--- a/.github/actions/setup-machine-charm/action.yml
+++ b/.github/actions/setup-machine-charm/action.yml
@@ -21,6 +21,14 @@ inputs:
   dashboard-charm-ref:
     description: Git reference to use when cloning charm repository.
     required: false
+  controller-app:
+    required: false
+    description: The Juju controller application.
+    default: controller
+  controller-model:
+    required: false
+    description: The model containing the Juju controller.
+    default: controller
 
 runs:
   using: "composite"
@@ -80,7 +88,7 @@ runs:
         charmcraft pack
       shell: bash
     - name: Switch to controller
-      run: juju switch controller
+      run: juju switch '${{ inputs.controller-model }}'
       shell: bash
     - name: Deploy charm from channel
       if: ${{ env.build_type == 'charm-release' }}
@@ -91,7 +99,7 @@ runs:
       run: juju deploy ${{ github.workspace }}/juju-dashboard-charm/machine-charm/juju-dashboard*.charm dashboard
       shell: bash
     - name: Integrate charm
-      run: juju integrate controller dashboard
+      run: juju integrate '${{ inputs.controller-app }}' dashboard
       shell: bash
     - name: Wait for charm to be ready
       run: juju wait-for application dashboard
diff --git a/.github/workflows/e2e-jimm-k8s-charm-oidc-auth.yml b/.github/workflows/e2e-jimm-k8s-charm-oidc-auth.yml
new file mode 100644
index 000000000..666a67593
--- /dev/null
+++ b/.github/workflows/e2e-jimm-k8s-charm-oidc-auth.yml
@@ -0,0 +1,77 @@
+name: "E2E tests: JIMM, k8s charm and OIDC"
+
+on:
+  workflow_call:
+    inputs:
+      admin-username:
+        required: false
+        type: string
+        description: The username to use when logging in as the admin user.
+        default: test@example.com
+      admin-password:
+        required: false
+        type: string
+        description: The password to use when logging in as the admin user.
+        default: test
+
+jobs:
+  test:
+    name: Run tests
+    runs-on: ubuntu-22.04
+    strategy:
+      matrix:
+        node-version: [22.x]
+    steps:
+      - name: Maximize build space
+        shell: bash
+        run: |
+            # Free enough space for everything to fit inside the runner.
+            df -h
+            sudo rm -rf /usr/share/dotnet
+            sudo rm -rf /usr/local/lib/android
+            sudo rm -rf /opt/ghc
+            sudo rm -rf /opt/hostedtoolcache/CodeQL
+            sudo docker image prune --all --force
+            sudo docker builder prune -a
+            df -h
+      - name: Checkout Juju Dashboard repo
+        uses: actions/checkout@v4
+      - name: Configure action
+        id: config
+        uses: ./.github/actions/prepare-action
+      - name: Start JIMM
+        id: jimm
+        uses: ./.github/actions/setup-jimm
+      - name: Set up Juju Dashboard k8s charm
+        uses: ./.github/actions/setup-k8s-charm
+        with:
+          charm-channel: ${{ steps.config.outputs.dashboard-charm-channel }}
+          controller-app: jimm
+          controller-model: ${{ env.CONTROLLER_NAME }}:jimm
+          dashboard-charm-ref: ${{ steps.config.outputs.dashboard-charm-ref }}
+          dashboard-ref: ${{ steps.config.outputs.repo-ref }}
+          dashboard-resource: ${{ steps.config.outputs.k8s-dashboard-resource }}
+      - name: Configure JIMM with dashboard address
+        shell: bash
+        run: |
+          juju switch "$CONTROLLER_NAME":jimm
+          DASHBOARD_ADDRESS=http://$(juju show-unit dashboard/0 | yq .dashboard/0.address):8080
+          juju config jimm cors-allowed-origins="$DASHBOARD_ADDRESS"
+          juju config jimm juju-dashboard-location="$DASHBOARD_ADDRESS"/models
+      - name: Run tests
+        uses: ./.github/actions/run-playwright
+        with:
+          setup: false # Playwright was set up in the setup-jimm action.
+          test-identifier: e2e-jimm-k8s-charm-oidc-auth
+        env:
+          CONTROLLER_NAME: ${{ steps.jimm.outputs.controller-name }}
+          AUTH_MODE: oidc
+          JUJU_ENV: jimm
+          PROVIDER: microk8s
+          USERNAME: ${{ inputs.admin-username }}
+          PASSWORD: ${{ inputs.admin-password }}
+      - name: Send notification on failure
+        if: steps.config.outputs.send-failure-notification && failure()
+        uses: ./.github/actions/send-notification
+        with:
+          webhook-url: ${{ secrets.WEBBOT_URL }}
diff --git a/.github/workflows/end-to-end.yml b/.github/workflows/end-to-end.yml
index 64b8db42a..187f14ba1 100644
--- a/.github/workflows/end-to-end.yml
+++ b/.github/workflows/end-to-end.yml
@@ -65,3 +65,8 @@ jobs:
     uses: ./.github/workflows/e2e-juju-k8s-charm-local-auth.yml
     secrets: inherit
     if:  github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e')
+  jimm-k8s-oidc:
+    name: Test JIMM, k8s-charm and oidc-auth
+    uses: ./.github/workflows/e2e-jimm-k8s-charm-oidc-auth.yml
+    secrets: inherit
+    if:  github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'e2e')
diff --git a/e2e/base/auth-validation.spec.ts b/e2e/base/auth-validation.spec.ts
index dac07bf94..5b73647f2 100644
--- a/e2e/base/auth-validation.spec.ts
+++ b/e2e/base/auth-validation.spec.ts
@@ -18,6 +18,10 @@ test.describe("Authentication Validation", () => {
       await expect(
         popup.getByText(`authentication failed for user "invalid-user"`),
       ).toBeVisible();
+    } else if (process.env.AUTH_MODE === "oidc") {
+      await expect(
+        page.getByText("incorrect username or password"),
+      ).toBeVisible();
     } else {
       await expect(
         page.getByText("Could not log into controller"),
@@ -32,10 +36,10 @@ test.describe("Authentication Validation", () => {
   }) => {
     // Skipping local auth as session is managed in Redux state, not persistent storage.
     test.skip(process.env.AUTH_MODE === "local");
-    await page.goto("/");
-    await authHelpers.login();
 
     if (process.env.AUTH_MODE === "candid") {
+      await page.goto("/");
+      await authHelpers.login();
       await page.evaluate(() => window.localStorage.clear());
       await expect(
         page.getByText("Controller authentication required").first(),
@@ -47,11 +51,15 @@ test.describe("Authentication Validation", () => {
           name: "jimm-browser-session",
           value: "random",
           path: "/",
-          domain: "localhost",
+          domain: "test-jimm.local",
+          httpOnly: true,
+          secure: true,
+          sameSite: "None",
         },
       ]);
+      await page.goto("/");
       await expect(
-        page.getByText("Authentication error.").first(),
+        page.getByText("Unable to check authentication status.").first(),
       ).toBeVisible();
       await expect(
         page.getByText("Log in to the dashboard").first(),
diff --git a/e2e/base/models.spec.ts b/e2e/base/models.spec.ts
index 7d891a083..e30010719 100644
--- a/e2e/base/models.spec.ts
+++ b/e2e/base/models.spec.ts
@@ -3,6 +3,9 @@ import { expect } from "@playwright/test";
 import { test } from "../fixtures/setup";
 
 test.describe("Models", () => {
+  // TODO: implement OIDC fixtures WD-21779.
+  test.skip(process.env.AUTH_MODE === "oidc");
+
   test.beforeAll(async ({ jujuHelpers, testOptions }) => {
     await jujuHelpers.jujuLogin();
     await jujuHelpers.addModel("foo");
diff --git a/e2e/helpers/auth-helpers.ts b/e2e/helpers/auth-helpers.ts
index 269992550..e976037ca 100644
--- a/e2e/helpers/auth-helpers.ts
+++ b/e2e/helpers/auth-helpers.ts
@@ -40,9 +40,25 @@ export class AuthHelpers {
     return popup;
   }
 
+  async loginOIDC(
+    userName: string = "test@example.com",
+    password: string = "test",
+  ) {
+    await this.page.goto("/");
+    await this.page
+      .getByRole("link", { name: "Log in to the dashboard" })
+      .click();
+    await this.page.getByRole("textbox", { name: "Email" }).fill(userName);
+    await this.page.getByRole("textbox", { name: "Password" }).fill(password);
+    await this.page.getByRole("button", { name: "Sign in" }).click();
+    return this.page;
+  }
+
   async login(userName?: string, password?: string) {
     if (process.env.AUTH_MODE === "candid") {
       return await this.loginCandid(userName, password);
+    } else if (process.env.AUTH_MODE === "oidc") {
+      return await this.loginOIDC(userName, password);
     } else {
       await this.loginLocal(userName, password);
     }
diff --git a/e2e/helpers/juju-helpers.ts b/e2e/helpers/juju-helpers.ts
index f971efd8a..e22509c72 100644
--- a/e2e/helpers/juju-helpers.ts
+++ b/e2e/helpers/juju-helpers.ts
@@ -1,7 +1,5 @@
 import fs from "node:fs/promises";
 
-import { exec } from "utils/exec";
-
 import {
   type Resource,
   CloudAccessTypes,
@@ -9,6 +7,7 @@ import {
   ResourceType,
   type TestOptions,
 } from "../fixtures/setup";
+import { exec } from "../utils/exec";
 
 export class JujuHelpers {
   private cleanupStack: Resource[];
diff --git a/e2e/helpers/user/oidc.ts b/e2e/helpers/user/oidc.ts
new file mode 100644
index 000000000..133654f28
--- /dev/null
+++ b/e2e/helpers/user/oidc.ts
@@ -0,0 +1,65 @@
+import type { Page } from "@playwright/test";
+import { expect } from "@playwright/test";
+import { chromium } from "playwright";
+
+import { exec } from "../../utils/exec";
+import { findLine } from "../../utils/findLine";
+
+type Secret = {
+  username: string;
+  password: string;
+};
+
+// The name to use to identify the authenticated jimm controller. This can be
+// anything, it does not need to match the real controller names.
+const JIMM_CONTROLLER = "jimm-k8s";
+
+export class OIDC {
+  static async loginCli(user: Secret): Promise<void> {
+    try {
+      await exec(`juju unregister ${JIMM_CONTROLLER} --no-prompt`);
+      console.log(`Unregistered ${JIMM_CONTROLLER}.`);
+    } catch (error) {
+      console.log("Not currently logged in.");
+    }
+    // Begin the login.
+    const loginProc = exec(
+      `juju login test-jimm.local:443 -c ${JIMM_CONTROLLER}`,
+    );
+    if (!loginProc.child.stderr) {
+      throw new Error("No output from login command.");
+    }
+    // Find the login line.
+    const loginLine = await findLine(loginProc.child.stderr, (line) =>
+      line.startsWith("Please visit"),
+    );
+    const loginURL = loginLine.match(/http\S+/)?.[0];
+    const loginCode = loginLine.match(/(?<=enter code ).\w+/)?.[0];
+    if (!loginURL || !loginCode) {
+      throw new Error("Login details not found.");
+    }
+    // Prepare a browser instance to go to the URL.
+    const browser = await chromium.launch();
+    // Manually set the context to ignore HTTPS errors, for the case where this
+    // function is called outside of the PlayWright runner to log in during the
+    // workflow.
+    const context = await browser.newContext({ ignoreHTTPSErrors: true });
+    const page = await context.newPage();
+    await page.goto(loginURL);
+    // Login with user credentials.
+    await OIDC.uiLogin(page, user, loginCode);
+    // Wait for the original process to finish.
+    await loginProc;
+    // Exit the browser so the script will finish when called outside of Playwright.
+    await browser.close();
+  }
+
+  private static async uiLogin(page: Page, secret: Secret, code: string) {
+    await page.getByRole("textbox", { name: "XXXXXXXX" }).fill(code);
+    await page.getByRole("button", { name: "Next" }).click();
+    await page.getByRole("textbox", { name: "Email" }).fill(secret.username);
+    await page.getByRole("textbox", { name: /Password/ }).fill(secret.password);
+    await page.getByRole("button", { name: "Sign in" }).click();
+    await expect(page.getByText("Sign in successful")).toBeVisible();
+  }
+}
diff --git a/src/utils/exec.ts b/e2e/utils/exec.ts
similarity index 100%
rename from src/utils/exec.ts
rename to e2e/utils/exec.ts
diff --git a/e2e/utils/findLine.ts b/e2e/utils/findLine.ts
new file mode 100644
index 000000000..79d9d49a6
--- /dev/null
+++ b/e2e/utils/findLine.ts
@@ -0,0 +1,55 @@
+import type { Readable } from "node:stream";
+
+export function findLine(
+  stream: Readable,
+  filter: (line: string, allLines: string[]) => boolean,
+): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const allLines: string[] = [];
+    let prefix: string = "";
+
+    function onData(data: string) {
+      const lines = data.split("\n");
+
+      if (lines.length > 0) {
+        lines[0] = prefix + lines[0];
+      }
+
+      prefix = lines.pop() ?? "";
+
+      for (const line of lines) {
+        allLines.push(line);
+
+        if (filter(line, allLines)) {
+          removeListeners();
+
+          // Produce the line
+          return resolve(line);
+        }
+      }
+    }
+
+    function onEnd() {
+      removeListeners();
+
+      allLines.push(prefix);
+
+      if (filter(prefix, allLines)) {
+        return resolve(prefix);
+      }
+
+      reject(
+        new Error("no line found matching filter", {
+          cause: { lines: allLines },
+        }),
+      );
+    }
+
+    function removeListeners() {
+      stream.off("data", onData);
+      stream.off("end", onEnd);
+    }
+
+    stream.on("data", onData).on("end", onEnd);
+  });
+}


Reviews:
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T05:39:41Z
user: login: andogq
body: Nice work! I've left one suggestion about moving the device login from a playwright test into a regular script, but that's up to you whether you think it's worthwhile or not!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T06:31:23Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T03:30:56Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T03:31:58Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-30T03:32:03Z
user: login: andogq
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-30T03:44:40Z
user: login: andogq
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-30T03:45:16Z
user: login: Ninfa-Jeon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T11:45:11Z
user: login: Ninfa-Jeon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T11:48:03Z
user: login: Ninfa-Jeon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T11:56:55Z
user: login: Ninfa-Jeon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T12:04:04Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T23:32:36Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T23:34:25Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T23:34:51Z
user: login: huwshimi
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T23:35:50Z
user: login: Ninfa-Jeon
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T04:25:33Z

Review comments:
diff_hunk: @@ -4,32 +4,45 @@ inputs:
   test-identifier:
     description: Unique identifier for this test run that will be used for uploading test results.
     required: true
+  run:
+    default: 'true'
+    description: Whether to run the test suite.
+    required: false
+  setup:
+    default: 'true'
+    description: Whether to set up Playwright.
+    required: false
path: .github/actions/run-playwright/action.yml
user: login: huwshimi
body: I added these so we reuse this action for setting up Playwright without running the tests and vice versa.
created_at: 2025-04-29T05:33:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,6 +1,14 @@
 name: Set up Juju Dashboard k8s charm
 description: Set up the Juju Dashboard k8s charm using the supplied versions.
 inputs:
+  controller-app:
+    required: false
+    description: The Juju controller application.
+    default: controller
+  controller-model:
+    required: false
+    description: The model containing the Juju controller.
+    default: controller
path: .github/actions/setup-k8s-charm/action.yml
user: login: huwshimi
body: With JIMM the controller model and app are different so these needed to be configurable.
created_at: 2025-04-29T05:35:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,30 +57,23 @@ runs:
         ref: ${{ inputs.dashboard-ref }}
         path: juju-dashboard-release
     - name: Build Juju Dashboard image
-      id: build-dashboard-image
       if: ${{ env.build_type == 'checkout' }}
       shell: bash
       run: |
         cd '${{ github.workspace }}/juju-dashboard-release'
-        DOCKER_BUILDKIT=1 docker build -t juju-dashboard .
-        docker image save juju-dashboard | sudo microk8s ctr image import -
-        echo "id=$(docker image inspect juju-dashboard --format '{{.ID}}')" >> "$GITHUB_OUTPUT"
-    - name: Set Juju Dashboard image
-      id: set-dashboard-image
-      if: ${{ env.build_type == 'dashboard-resource' }}
-      shell: bash
-      run: echo "id=canonicalwebteam/juju-dashboard:${{ inputs.dashboard-resource }}" >> "$GITHUB_OUTPUT"
+        DOCKER_BUILDKIT=1 docker build -t juju-dashboard:local .
+        docker image save juju-dashboard:local | sudo microk8s ctr image import -
path: .github/actions/setup-k8s-charm/action.yml
user: login: huwshimi
body: I switched to using a `local` tag to reference the image rather than the id. This might not have been necessary in the end, but I've left this change as it streamlines this action a little bit.
created_at: 2025-04-29T05:37:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,173 @@
+name: Set up JIMM
+description: Set up JIMM with provided configuration.
+inputs:
+  admin-username:
+    required: false
+    description: The username to use when logging in as the admin user.
+    default: test@example.com
+  admin-password:
+    required: false
+    description: The password to use when logging in as the admin user.
+    default: test
+  jimm-channel:
+    description: The JIMM charm channel to use.
+    required: false
+    default: 3/edge
+  juju-channel:
+    description: Channel of Juju to use.
+    required: false
+    default: 3/stable
+outputs:
+  controller-name:
+    description: Name of the JIMM workloads controller
+    value: ${{ steps.workloads-controller.outputs.name }}
+
+runs:
+  using: composite
+  steps:
+    - name: Disable IPv6
+      shell: bash
+      run: |
+        echo "net.ipv6.conf.all.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.default.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.lo.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        sudo sysctl -p
+    - name: Setup operator environment
+      uses: charmed-kubernetes/actions-operator@main
+      with:
+        channel: 1.32-strict/stable
+        juju-channel: ${{ inputs.juju-channel }}
+        provider: microk8s
+        microk8s-group: snap_microk8s
+        microk8s-addons: "hostpath-storage dns ingress host-access metallb:10.64.140.43-10.64.140.49"
+    - name: Set up deps
+      shell: bash
+      run: |
+        sudo snap install yq vault
+        sudo snap install --channel=3/edge jimmctl
+    - name: Setup Go
+      uses: actions/setup-go@v4
+    - name: Set up IAM
+      shell: bash
+      run: |
+        juju add-model iam
+        juju deploy identity-platform --trust --channel latest/edge
+        # Wait for everything to be ready except kratos-external-idp-integrator
+        # which will remain as blocked as we're not using an external identity provider.
+        juju wait-for model iam --query='forEach(applications, app => (app.name == "kratos-external-idp-integrator" && app.status=="blocked") || (app.name != "kratos-external-idp-integrator" && app.status=="active"))' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju offer hydra:oauth
+        juju offer self-signed-certificates:send-ca-cert
+    - name: Create admin user
+      shell: bash
+      run: |
+        juju config kratos enforce_mfa=False
+        juju wait-for unit kratos/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        IDENTITY_ID=$(juju run --format=json kratos/0 create-admin-account email='${{ inputs.admin-username }}' password=${{ inputs.admin-password }} username=admin | yq .kratos/0.results.identity-id)
path: .github/actions/setup-jimm/action.yml
user: login: andogq
body: ```suggestion
        IDENTITY_ID=$(juju run --format=json kratos/0 create-admin-account email='${{ inputs.admin-username }}' password='${{ inputs.admin-password }}' username=admin | yq .kratos/0.results.identity-id)
```

Absolute _mega_ nit.

Also, what's the relationship between `email`, `inputs.admin-username`, and `username` here? Is it fine to hard-code `username` to `admin`?
created_at: 2025-04-29T06:13:32Z
author_association: MEMBER
path: .github/actions/setup-jimm/cli-login/login.spec.ts
user: login: andogq
body: Would it be better to change this into a regular NodeJS script, so we can directly run it without having to deal with Playwright-Test? I believe you can use `import { chromium } from "playwright"` to get a browser instance, then go from there.
created_at: 2025-04-29T06:18:27Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+name: Set up JIMM
+description: Set up JIMM with provided configuration.
+inputs:
+  admin-username:
+    required: false
+    description: The username to use when logging in as the admin user.
+    default: test@example.com
+  admin-password:
+    required: false
+    description: The password to use when logging in as the admin user.
+    default: test
+  jimm-channel:
+    description: The JIMM charm channel to use.
+    required: false
+    default: 3/edge
+  juju-channel:
+    description: Channel of Juju to use.
+    required: false
+    default: 3/stable
+outputs:
+  controller-name:
+    description: Name of the JIMM workloads controller
+    value: ${{ steps.workloads-controller.outputs.name }}
+
+runs:
+  using: composite
+  steps:
+    - name: Disable IPv6
+      shell: bash
+      run: |
+        echo "net.ipv6.conf.all.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.default.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.lo.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        sudo sysctl -p
+    - name: Setup operator environment
+      uses: charmed-kubernetes/actions-operator@main
+      with:
+        channel: 1.32-strict/stable
+        juju-channel: ${{ inputs.juju-channel }}
+        provider: microk8s
+        microk8s-group: snap_microk8s
+        microk8s-addons: "hostpath-storage dns ingress host-access metallb:10.64.140.43-10.64.140.49"
+    - name: Set up deps
+      shell: bash
+      run: |
+        sudo snap install yq vault
+        sudo snap install --channel=3/edge jimmctl
+    - name: Setup Go
+      uses: actions/setup-go@v4
+    - name: Set up IAM
+      shell: bash
+      run: |
+        juju add-model iam
+        juju deploy identity-platform --trust --channel latest/edge
+        # Wait for everything to be ready except kratos-external-idp-integrator
+        # which will remain as blocked as we're not using an external identity provider.
+        juju wait-for model iam --query='forEach(applications, app => (app.name == "kratos-external-idp-integrator" && app.status=="blocked") || (app.name != "kratos-external-idp-integrator" && app.status=="active"))' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju offer hydra:oauth
+        juju offer self-signed-certificates:send-ca-cert
+    - name: Create admin user
+      shell: bash
+      run: |
+        juju config kratos enforce_mfa=False
+        juju wait-for unit kratos/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        IDENTITY_ID=$(juju run --format=json kratos/0 create-admin-account email='${{ inputs.admin-username }}' password=${{ inputs.admin-password }} username=admin | yq .kratos/0.results.identity-id)
+        SECRET_ID=$(juju add-secret password-secret password=test)
+        juju grant-secret password-secret kratos
+        juju run kratos/0 reset-password identity-id=$IDENTITY_ID password-secret-id=$SECRET_ID
+    - name: Set up JIMM
+      shell: bash
+      run: |
+        juju add-model jimm
+        juju deploy juju-jimm-k8s --channel=${{ inputs.jimm-channel }} jimm
+        juju deploy openfga-k8s --channel=edge openfga
+        juju deploy postgresql-k8s --channel=14/stable postgresql
+        juju deploy vault-k8s --channel=1.15/beta vault
+        juju deploy nginx-ingress-integrator --channel=latest/stable --trust ingress
+        juju trust postgresql --scope=cluster
+        # Wait for postgres to prevent issues when relating to openfga: https://github.com/canonical/openfga-operator/issues/25.
+        juju wait-for unit postgresql/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        juju relate jimm:nginx-route ingress
+        juju relate jimm:openfga openfga
+        juju relate jimm:database postgresql
+        juju relate jimm:vault vault
+        juju relate openfga:database postgresql
+        juju wait-for application openfga --query='name=="openfga" && (status=="active" || status=="error")' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju resolved openfga/0 # Sometimes the openfga unit will get into an error state, so resolve and try again.
+        juju wait-for unit openfga/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        juju relate jimm admin/iam.hydra
+        juju relate jimm admin/iam.self-signed-certificates
+        juju deploy self-signed-certificates jimm-cert
+        juju relate ingress:certificates jimm-cert:certificates
+    - name: Set up Vault
+      shell: bash
+      run: |
+        export VAULT_ADDR=https://$(juju status vault/leader --format=yaml | yq '.applications.vault.address'):8200; echo "Vault address =" "$VAULT_ADDR"
+        cert_juju_secret_id=$(juju secrets --format=yaml | yq 'to_entries | .[] | select(.value.label == "self-signed-vault-ca-certificate") | .key'); echo "Vault ca-cert secret ID =" "$cert_juju_secret_id"
+        juju show-secret ${cert_juju_secret_id} --reveal --format=yaml | yq '.[].content.certificate' > vault.pem && echo "saved certificate contents to vault.pem"
+        export VAULT_CAPATH=$(pwd)/vault.pem; echo "Setting VAULT_CAPATH from" "$VAULT_CAPATH"
+        key_init=$(vault operator init -key-shares=1 -key-threshold=1); echo "$key_init"
+        export VAULT_TOKEN=$(echo "$key_init" | sed -n -e 's/.*Root Token: //p'); echo "RootToken = $VAULT_TOKEN"
+        export UNSEAL_KEY=$(echo "$key_init" | sed -n -e 's/.*Unseal Key 1: //p'); echo "UnsealKey = $UNSEAL_KEY"
+        vault operator unseal "$UNSEAL_KEY"
+        vault_secret_id=$(juju add-secret vault-token token="$VAULT_TOKEN")
+        juju grant-secret vault-token vault
+        juju run vault/leader authorize-charm secret-id="$vault_secret_id"
+        juju remove-secret "vault-token"
+    - name: Configure JIMM
+      shell: bash
+      run: |
+        # Give the user admin permissions so that they can add the workload controller.
+        juju config jimm controller-admins="${{ inputs.admin-username }}"
+        juju config jimm uuid=3f4d142b-732e-4e99-80e7-5899b7e67e59
+        juju config jimm dns-name=test-jimm.local
+        KEYS=$(go run github.com/go-macaroon-bakery/macaroon-bakery/cmd/bakery-keygen/v3@latest)
+        juju config jimm public-key=$(echo $KEYS | yq .public)
+        juju config jimm private-key=$(echo $KEYS | yq .private)
+        juju wait-for application ingress --query='name=="ingress" && (status=="active" || status=="error")' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju resolved ingress/0 # Sometimes the ingress unit will get into an error state, so resolve and try again.
+        juju wait-for unit ingress/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        # Point the test-jimm.local hostname to the ingress address.
+        echo "$(juju status ingress/0 --format=json | yq '.applications.ingress.units.ingress/0.workload-status.message | sub("Ingress IP\\(s\\): ", "")') test-jimm.local" | sudo tee -a /etc/hosts
+        juju run jimm-cert/0 get-ca-certificate --quiet | yq .ca-certificate | sudo tee /usr/local/share/ca-certificates/jimm-test.crt
+        sudo update-ca-certificates --fresh
+    - name: Bootstrap workloads controller
+      id: workloads-controller
+      shell: bash
+      run: |
+        # Wait for JIMM to be ready if it's still making config changes.
+        juju wait-for unit jimm/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        WORKLOADS_NAME=workload-microk8s
+        sg snap_microk8s -c "juju bootstrap microk8s $WORKLOADS_NAME --config login-token-refresh-url=http://jimm-endpoints.jimm.svc.cluster.local:8080/.well-known/jwks.json"
+        echo "name=$WORKLOADS_NAME" >> $GITHUB_OUTPUT
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v4
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: "yarn"
+    - name: Set up Playwright
+      uses: ./.github/actions/run-playwright
+      with:
+        run: false
+    - name: Log in to JIMM with Juju CLI
+      shell: bash
+      run: |
+        juju switch "$CONTROLLER_NAME":jimm
+        # Export the identity address to be used by Playwright to log in.
+        export IAM_ADDRESS=$(juju status -m iam --format yaml | yq .applications.traefik-public.address)
+        # Start the login. This will output the device code to a file and puts the login process into the background while it waits for us to log in.
+        juju login test-jimm.local:443 -c jimm-k8s >~/login.txt 2>&1 &
+        # Wait for the login message to be output.
+        timeout 1m grep -m 1 'enter code' <(tail -f ~/login.txt)
+        # Get the device code from th efile.
+        export DEVICE_CODE=$(grep -oP "(?<=code\ )(.+)(?=\ to)" ~/login.txt); echo "Device code: $DEVICE_CODE"
+        # Use Playwright to step through the login flow.
+        yarn playwright test -c ${{ github.workspace }}/.github/actions/setup-jimm/cli-login
+        # Wait for the login process to complete.
+        timeout 1m grep -m 1 Welcome <(tail -f ~/login.txt)
path: .github/actions/setup-jimm/action.yml
user: login: andogq
body: If you're interested in converting this to a regular script, you could follow an approach similar to what I did [here](https://github.com/canonical/juju-dashboard/pull/1940/files#diff-f9579a5c0d4752ecff5a36ed67e4c0ec0c773e58053eccf4fed4fba60f490bddR96-R119).

Essentially, spawn the login command from JS, monitor stdout for the URL, and pass it directly to playwright.
created_at: 2025-04-29T06:20:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,173 @@
+name: Set up JIMM
+description: Set up JIMM with provided configuration.
+inputs:
+  admin-username:
+    required: false
+    description: The username to use when logging in as the admin user.
+    default: test@example.com
+  admin-password:
+    required: false
+    description: The password to use when logging in as the admin user.
+    default: test
+  jimm-channel:
+    description: The JIMM charm channel to use.
+    required: false
+    default: 3/edge
+  juju-channel:
+    description: Channel of Juju to use.
+    required: false
+    default: 3/stable
+outputs:
+  controller-name:
+    description: Name of the JIMM workloads controller
+    value: ${{ steps.workloads-controller.outputs.name }}
+
+runs:
+  using: composite
+  steps:
+    - name: Disable IPv6
+      shell: bash
+      run: |
+        echo "net.ipv6.conf.all.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.default.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.lo.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        sudo sysctl -p
+    - name: Setup operator environment
+      uses: charmed-kubernetes/actions-operator@main
+      with:
+        channel: 1.32-strict/stable
+        juju-channel: ${{ inputs.juju-channel }}
+        provider: microk8s
+        microk8s-group: snap_microk8s
+        microk8s-addons: "hostpath-storage dns ingress host-access metallb:10.64.140.43-10.64.140.49"
+    - name: Set up deps
+      shell: bash
+      run: |
+        sudo snap install yq vault
+        sudo snap install --channel=3/edge jimmctl
+    - name: Setup Go
+      uses: actions/setup-go@v4
+    - name: Set up IAM
+      shell: bash
+      run: |
+        juju add-model iam
+        juju deploy identity-platform --trust --channel latest/edge
+        # Wait for everything to be ready except kratos-external-idp-integrator
+        # which will remain as blocked as we're not using an external identity provider.
+        juju wait-for model iam --query='forEach(applications, app => (app.name == "kratos-external-idp-integrator" && app.status=="blocked") || (app.name != "kratos-external-idp-integrator" && app.status=="active"))' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju offer hydra:oauth
+        juju offer self-signed-certificates:send-ca-cert
+    - name: Create admin user
+      shell: bash
+      run: |
+        juju config kratos enforce_mfa=False
+        juju wait-for unit kratos/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        IDENTITY_ID=$(juju run --format=json kratos/0 create-admin-account email='${{ inputs.admin-username }}' password=${{ inputs.admin-password }} username=admin | yq .kratos/0.results.identity-id)
path: .github/actions/setup-jimm/action.yml
user: login: huwshimi
body: My assumption here was that by setting the username to the same name as the `admin` user that was created when bootstrapping Juju, that these two users were being associated, but I can't actually see anything in the docs to support that.

We log in using the `email` (`inputs.admin-username`) and it seems like `username` just needs to be a unique value (but we don't use it anywhere). I don't think it matters what we use so I've changed it to `inputs.admin-username`.
created_at: 2025-04-30T03:30:56Z
author_association: CONTRIBUTOR
path: .github/actions/setup-jimm/cli-login/login.spec.ts
user: login: huwshimi
body: Done.
created_at: 2025-04-30T03:31:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,173 @@
+name: Set up JIMM
+description: Set up JIMM with provided configuration.
+inputs:
+  admin-username:
+    required: false
+    description: The username to use when logging in as the admin user.
+    default: test@example.com
+  admin-password:
+    required: false
+    description: The password to use when logging in as the admin user.
+    default: test
+  jimm-channel:
+    description: The JIMM charm channel to use.
+    required: false
+    default: 3/edge
+  juju-channel:
+    description: Channel of Juju to use.
+    required: false
+    default: 3/stable
+outputs:
+  controller-name:
+    description: Name of the JIMM workloads controller
+    value: ${{ steps.workloads-controller.outputs.name }}
+
+runs:
+  using: composite
+  steps:
+    - name: Disable IPv6
+      shell: bash
+      run: |
+        echo "net.ipv6.conf.all.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.default.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.lo.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        sudo sysctl -p
+    - name: Setup operator environment
+      uses: charmed-kubernetes/actions-operator@main
+      with:
+        channel: 1.32-strict/stable
+        juju-channel: ${{ inputs.juju-channel }}
+        provider: microk8s
+        microk8s-group: snap_microk8s
+        microk8s-addons: "hostpath-storage dns ingress host-access metallb:10.64.140.43-10.64.140.49"
+    - name: Set up deps
+      shell: bash
+      run: |
+        sudo snap install yq vault
+        sudo snap install --channel=3/edge jimmctl
+    - name: Setup Go
+      uses: actions/setup-go@v4
+    - name: Set up IAM
+      shell: bash
+      run: |
+        juju add-model iam
+        juju deploy identity-platform --trust --channel latest/edge
+        # Wait for everything to be ready except kratos-external-idp-integrator
+        # which will remain as blocked as we're not using an external identity provider.
+        juju wait-for model iam --query='forEach(applications, app => (app.name == "kratos-external-idp-integrator" && app.status=="blocked") || (app.name != "kratos-external-idp-integrator" && app.status=="active"))' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju offer hydra:oauth
+        juju offer self-signed-certificates:send-ca-cert
+    - name: Create admin user
+      shell: bash
+      run: |
+        juju config kratos enforce_mfa=False
+        juju wait-for unit kratos/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        IDENTITY_ID=$(juju run --format=json kratos/0 create-admin-account email='${{ inputs.admin-username }}' password=${{ inputs.admin-password }} username=admin | yq .kratos/0.results.identity-id)
+        SECRET_ID=$(juju add-secret password-secret password=test)
+        juju grant-secret password-secret kratos
+        juju run kratos/0 reset-password identity-id=$IDENTITY_ID password-secret-id=$SECRET_ID
+    - name: Set up JIMM
+      shell: bash
+      run: |
+        juju add-model jimm
+        juju deploy juju-jimm-k8s --channel=${{ inputs.jimm-channel }} jimm
+        juju deploy openfga-k8s --channel=edge openfga
+        juju deploy postgresql-k8s --channel=14/stable postgresql
+        juju deploy vault-k8s --channel=1.15/beta vault
+        juju deploy nginx-ingress-integrator --channel=latest/stable --trust ingress
+        juju trust postgresql --scope=cluster
+        # Wait for postgres to prevent issues when relating to openfga: https://github.com/canonical/openfga-operator/issues/25.
+        juju wait-for unit postgresql/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        juju relate jimm:nginx-route ingress
+        juju relate jimm:openfga openfga
+        juju relate jimm:database postgresql
+        juju relate jimm:vault vault
+        juju relate openfga:database postgresql
+        juju wait-for application openfga --query='name=="openfga" && (status=="active" || status=="error")' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju resolved openfga/0 # Sometimes the openfga unit will get into an error state, so resolve and try again.
+        juju wait-for unit openfga/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        juju relate jimm admin/iam.hydra
+        juju relate jimm admin/iam.self-signed-certificates
+        juju deploy self-signed-certificates jimm-cert
+        juju relate ingress:certificates jimm-cert:certificates
+    - name: Set up Vault
+      shell: bash
+      run: |
+        export VAULT_ADDR=https://$(juju status vault/leader --format=yaml | yq '.applications.vault.address'):8200; echo "Vault address =" "$VAULT_ADDR"
+        cert_juju_secret_id=$(juju secrets --format=yaml | yq 'to_entries | .[] | select(.value.label == "self-signed-vault-ca-certificate") | .key'); echo "Vault ca-cert secret ID =" "$cert_juju_secret_id"
+        juju show-secret ${cert_juju_secret_id} --reveal --format=yaml | yq '.[].content.certificate' > vault.pem && echo "saved certificate contents to vault.pem"
+        export VAULT_CAPATH=$(pwd)/vault.pem; echo "Setting VAULT_CAPATH from" "$VAULT_CAPATH"
+        key_init=$(vault operator init -key-shares=1 -key-threshold=1); echo "$key_init"
+        export VAULT_TOKEN=$(echo "$key_init" | sed -n -e 's/.*Root Token: //p'); echo "RootToken = $VAULT_TOKEN"
+        export UNSEAL_KEY=$(echo "$key_init" | sed -n -e 's/.*Unseal Key 1: //p'); echo "UnsealKey = $UNSEAL_KEY"
+        vault operator unseal "$UNSEAL_KEY"
+        vault_secret_id=$(juju add-secret vault-token token="$VAULT_TOKEN")
+        juju grant-secret vault-token vault
+        juju run vault/leader authorize-charm secret-id="$vault_secret_id"
+        juju remove-secret "vault-token"
+    - name: Configure JIMM
+      shell: bash
+      run: |
+        # Give the user admin permissions so that they can add the workload controller.
+        juju config jimm controller-admins="${{ inputs.admin-username }}"
+        juju config jimm uuid=3f4d142b-732e-4e99-80e7-5899b7e67e59
+        juju config jimm dns-name=test-jimm.local
+        KEYS=$(go run github.com/go-macaroon-bakery/macaroon-bakery/cmd/bakery-keygen/v3@latest)
+        juju config jimm public-key=$(echo $KEYS | yq .public)
+        juju config jimm private-key=$(echo $KEYS | yq .private)
+        juju wait-for application ingress --query='name=="ingress" && (status=="active" || status=="error")' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju resolved ingress/0 # Sometimes the ingress unit will get into an error state, so resolve and try again.
+        juju wait-for unit ingress/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        # Point the test-jimm.local hostname to the ingress address.
+        echo "$(juju status ingress/0 --format=json | yq '.applications.ingress.units.ingress/0.workload-status.message | sub("Ingress IP\\(s\\): ", "")') test-jimm.local" | sudo tee -a /etc/hosts
+        juju run jimm-cert/0 get-ca-certificate --quiet | yq .ca-certificate | sudo tee /usr/local/share/ca-certificates/jimm-test.crt
+        sudo update-ca-certificates --fresh
+    - name: Bootstrap workloads controller
+      id: workloads-controller
+      shell: bash
+      run: |
+        # Wait for JIMM to be ready if it's still making config changes.
+        juju wait-for unit jimm/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        WORKLOADS_NAME=workload-microk8s
+        sg snap_microk8s -c "juju bootstrap microk8s $WORKLOADS_NAME --config login-token-refresh-url=http://jimm-endpoints.jimm.svc.cluster.local:8080/.well-known/jwks.json"
+        echo "name=$WORKLOADS_NAME" >> $GITHUB_OUTPUT
+    - name: Use Node.js ${{ matrix.node-version }}
+      uses: actions/setup-node@v4
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: "yarn"
+    - name: Set up Playwright
+      uses: ./.github/actions/run-playwright
+      with:
+        run: false
+    - name: Log in to JIMM with Juju CLI
+      shell: bash
+      run: |
+        juju switch "$CONTROLLER_NAME":jimm
+        # Export the identity address to be used by Playwright to log in.
+        export IAM_ADDRESS=$(juju status -m iam --format yaml | yq .applications.traefik-public.address)
+        # Start the login. This will output the device code to a file and puts the login process into the background while it waits for us to log in.
+        juju login test-jimm.local:443 -c jimm-k8s >~/login.txt 2>&1 &
+        # Wait for the login message to be output.
+        timeout 1m grep -m 1 'enter code' <(tail -f ~/login.txt)
+        # Get the device code from th efile.
+        export DEVICE_CODE=$(grep -oP "(?<=code\ )(.+)(?=\ to)" ~/login.txt); echo "Device code: $DEVICE_CODE"
+        # Use Playwright to step through the login flow.
+        yarn playwright test -c ${{ github.workspace }}/.github/actions/setup-jimm/cli-login
+        # Wait for the login process to complete.
+        timeout 1m grep -m 1 Welcome <(tail -f ~/login.txt)
path: .github/actions/setup-jimm/action.yml
user: login: huwshimi
body: Done.
created_at: 2025-04-30T03:32:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,173 @@
+name: Set up JIMM
+description: Set up JIMM with provided configuration.
+inputs:
+  admin-username:
+    required: false
+    description: The username to use when logging in as the admin user.
+    default: test@example.com
+  admin-password:
+    required: false
+    description: The password to use when logging in as the admin user.
+    default: test
+  jimm-channel:
+    description: The JIMM charm channel to use.
+    required: false
+    default: 3/edge
+  juju-channel:
+    description: Channel of Juju to use.
+    required: false
+    default: 3/stable
+outputs:
+  controller-name:
+    description: Name of the JIMM workloads controller
+    value: ${{ steps.workloads-controller.outputs.name }}
+
+runs:
+  using: composite
+  steps:
+    - name: Disable IPv6
+      shell: bash
+      run: |
+        echo "net.ipv6.conf.all.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.default.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        echo "net.ipv6.conf.lo.disable_ipv6=1" | sudo tee -a /etc/sysctl.conf
+        sudo sysctl -p
+    - name: Setup operator environment
+      uses: charmed-kubernetes/actions-operator@main
+      with:
+        channel: 1.32-strict/stable
+        juju-channel: ${{ inputs.juju-channel }}
+        provider: microk8s
+        microk8s-group: snap_microk8s
+        microk8s-addons: "hostpath-storage dns ingress host-access metallb:10.64.140.43-10.64.140.49"
+    - name: Set up deps
+      shell: bash
+      run: |
+        sudo snap install yq vault
+        sudo snap install --channel=3/edge jimmctl
+    - name: Setup Go
+      uses: actions/setup-go@v4
+    - name: Set up IAM
+      shell: bash
+      run: |
+        juju add-model iam
+        juju deploy identity-platform --trust --channel latest/edge
+        # Wait for everything to be ready except kratos-external-idp-integrator
+        # which will remain as blocked as we're not using an external identity provider.
+        juju wait-for model iam --query='forEach(applications, app => (app.name == "kratos-external-idp-integrator" && app.status=="blocked") || (app.name != "kratos-external-idp-integrator" && app.status=="active"))' || true # continue if this gets stuck because it stops getting status updates and times out
+        juju offer hydra:oauth
+        juju offer self-signed-certificates:send-ca-cert
+    - name: Create admin user
+      shell: bash
+      run: |
+        juju config kratos enforce_mfa=False
+        juju wait-for unit kratos/0 || true # continue if this gets stuck because it stops getting status updates and times out
+        IDENTITY_ID=$(juju run --format=json kratos/0 create-admin-account email='${{ inputs.admin-username }}' password=${{ inputs.admin-password }} username=admin | yq .kratos/0.results.identity-id)
path: .github/actions/setup-jimm/action.yml
user: login: andogq
body: Thanks for the clarification, that seems reasonable to me!
created_at: 2025-04-30T03:45:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,15 @@
+import { OIDC } from "../../../e2e/helpers/user/oidc";
+
+const username = process.env.USERNAME;
+const password = process.env.PASSWORD;
path: .github/actions/setup-jimm/cli-login.ts
user: login: Ninfa-Jeon
body: We are no longer using these environment variables. Check: https://github.com/canonical/juju-dashboard/pull/1943#discussion_r2062910702
```suggestion
const username = process.env.ADMIN_USERNAME;
const password = process.env.ADMIN_PASSWORD;
```
created_at: 2025-05-02T11:45:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,6 +21,14 @@ inputs:
   dashboard-charm-ref:
     description: Git reference to use when cloning charm repository.
     required: false
+  controller-app:
+    required: false
+    description: The Juju controller application.
+    default: controller
+  controller-model:
+    required: false
+    description: The model containing the Juju controller.
+    default: controller
path: .github/actions/setup-machine-charm/action.yml
user: login: Ninfa-Jeon
body: I wonder if this change was needed as currently we don't have a JIMM with machine charm workflow
created_at: 2025-05-02T11:48:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,9 +40,25 @@ export class AuthHelpers {
     return popup;
   }
 
+  async loginOIDC(
+    userName: string = "test@example.com",
+    password: string = "test",
+  ) {
+    await this.page.goto("/");
+    await this.page
+      .getByRole("link", { name: "Log in to the dashboard" })
+      .click();
+    await this.page.getByRole("textbox", { name: "Email" }).fill(userName);
+    await this.page.getByRole("textbox", { name: "Password" }).fill(password);
+    await this.page.getByRole("button", { name: "Sign in" }).click();
+    return this.page;
path: e2e/helpers/auth-helpers.ts
user: login: Ninfa-Jeon
body: We don't need to return the `page` object as it will be available to the `test` function. It was impossible to get a hold of the `popup` element in case of candid flow and as it was required to invalidate a faulty auth attempt, we were returning it. I doubt we need this.
created_at: 2025-05-02T11:56:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,65 @@
+import type { Page } from "@playwright/test";
+import { expect } from "@playwright/test";
+import { chromium } from "playwright";
+
+import { exec } from "../../utils/exec";
+import { findLine } from "../../utils/findLine";
+
+type Secret = {
+  username: string;
+  password: string;
+};
+
+// The name to use to identify the authenticated jimm controller. This can be
+// anything, it does not need to match the real controller names.
+const JIMM_CONTROLLER = "jimm-k8s";
+
+export class OIDC {
+  static async loginCli(user: Secret): Promise<void> {
path: e2e/helpers/user/oidc.ts
user: login: Ninfa-Jeon
body: ```suggestion
  static async loginCLI(user: Secret): Promise<void> {
```
created_at: 2025-05-02T12:04:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+import { OIDC } from "../../../e2e/helpers/user/oidc";
+
+const username = process.env.USERNAME;
+const password = process.env.PASSWORD;
path: .github/actions/setup-jimm/cli-login.ts
user: login: huwshimi
body: These are different from the admin user so I think it's fine to keep as different names, they're only used by this script: https://github.com/canonical/juju-dashboard/blob/main/.github/actions/setup-jimm/cli-login.ts#L3 which is used to log in so we can add the JIMM workloads controller as part of the JIMM set up.
created_at: 2025-05-04T23:32:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,6 +21,14 @@ inputs:
   dashboard-charm-ref:
     description: Git reference to use when cloning charm repository.
     required: false
+  controller-app:
+    required: false
+    description: The Juju controller application.
+    default: controller
+  controller-model:
+    required: false
+    description: The model containing the Juju controller.
+    default: controller
path: .github/actions/setup-machine-charm/action.yml
user: login: huwshimi
body: You're right, I added this for consistency with the k8s action, but we don't actually need this so we could remove it.
created_at: 2025-05-04T23:34:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,9 +40,25 @@ export class AuthHelpers {
     return popup;
   }
 
+  async loginOIDC(
+    userName: string = "test@example.com",
+    password: string = "test",
+  ) {
+    await this.page.goto("/");
+    await this.page
+      .getByRole("link", { name: "Log in to the dashboard" })
+      .click();
+    await this.page.getByRole("textbox", { name: "Email" }).fill(userName);
+    await this.page.getByRole("textbox", { name: "Password" }).fill(password);
+    await this.page.getByRole("button", { name: "Sign in" }).click();
+    return this.page;
path: e2e/helpers/auth-helpers.ts
user: login: huwshimi
body: Ah OK, we can remove that then. Thanks!
created_at: 2025-05-04T23:34:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,65 @@
+import type { Page } from "@playwright/test";
+import { expect } from "@playwright/test";
+import { chromium } from "playwright";
+
+import { exec } from "../../utils/exec";
+import { findLine } from "../../utils/findLine";
+
+type Secret = {
+  username: string;
+  password: string;
+};
+
+// The name to use to identify the authenticated jimm controller. This can be
+// anything, it does not need to match the real controller names.
+const JIMM_CONTROLLER = "jimm-k8s";
+
+export class OIDC {
+  static async loginCli(user: Secret): Promise<void> {
path: e2e/helpers/user/oidc.ts
user: login: huwshimi
body: Thanks, I think there are a few things that will need to be updated to match the new names in Tom's PR. We might as well wait until that lands and we extend the base class which will complain about the different names.
created_at: 2025-05-04T23:35:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+import { OIDC } from "../../../e2e/helpers/user/oidc";
+
+const username = process.env.USERNAME;
+const password = process.env.PASSWORD;
path: .github/actions/setup-jimm/cli-login.ts
user: login: Ninfa-Jeon
body: Understood. Thanks for the clarification!
created_at: 2025-05-05T04:25:33Z
author_association: CONTRIBUTOR

PR comments:
user: login: webteam-app
created_at: 2025-04-22T06:41:28Z
body: ### [<img src='https://assets.ubuntu.com/v1/6baef514-ubuntu-circle-of-friends-large.svg' height=32 width=32> Demo</img>](https://juju-dashboard-1942.demos.haus)
### [<img src='https://assets.ubuntu.com/v1/e512b0e2-jenkins.svg' height=32 width=32> Jenkins </img>](https://jenkins.canonical.com/webteam/job/start-demo/11955)
### [<img src='https://assets.ubuntu.com/v1/7144ec6d-logo-jaas-icon.svg' height=32 width=32> demos.haus </img>](https://demos.haus)

author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-22T06:45:03Z
body: <h2>Coverage Report</h2>
<table> <thead> <tr> <th align="center">Status</th> <th align="left">Category</th> <th align="right">Percentage</th> <th align="right">Covered / Total</th> </tr> </thead> <tbody> <tr> <td align="center">🟢</td> <td align="left">Lines</td> <td align="right">98.02% (🎯 95%)</td> <td align="right">14564 / 14858</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Statements</td> <td align="right">98.02% (🎯 95%)</td> <td align="right">14564 / 14858</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Functions</td> <td align="right">98.05% (🎯 95%)</td> <td align="right">605 / 617</td> </tr> <tr> <td align="center">🟢</td> <td align="left">Branches</td> <td align="right">92.59% (🎯 90%)</td> <td align="right">3087 / 3334</td> </tr> </tbody> </table><details><summary>File Coverage</summary>No changed files found.</details>
<em>Generated in workflow <a href=https://github.com/canonical/juju-dashboard/actions/runs/14746103203>#921</a> for commit <a href="https://github.com/canonical/juju-dashboard/commit/a12c03d7046f78b784d7341a4e6e99833fb112ad">a12c03d</a> by the <a href="https://github.com/davelosert/vitest-coverage-report-action">Vitest Coverage Report Action</a></em>

<!-- vitest-coverage-report-marker-root -->
author_association: NONE