Skip to content

Gundi Keycloak login theme (KC 11 + 26) - #482

Merged
chrisdoehring merged 19 commits into
mainfrom
feature/keycloak-gundi-login-theme
Sep 28, 2026
Merged

chrisdoehring merged 19 commits into
mainfrom
feature/keycloak-gundi-login-theme

Conversation

@chrisdoehring

@chrisdoehring chrisdoehring commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Jira: GUNDI-5744

Implements docs/superpowers/specs/2026-09-22-keycloak-gundi-login-theme-design.md (spec and plan are in this branch).

A gundi Keycloak login theme that restyles the stock layout to match the React Gundi portal, built from one source folder for both Keycloak 11.0.2 (prod today) and Keycloak 26.7 (the planned upgrade). CSS and properties only, no FreeMarker overrides, so every login-flow page inherits it.

What's in here

  • keycloak/themes/gundi/login/ — shared tokens + stylesheet against stable #kc-* ids, one small PatternFly 3 file for 11 and one PatternFly 5 file for 26, two properties files, bundled Inter, logo mark, favicon, two message overrides
  • Layout follows the EarthRanger ID login: Gundi mark inside the card, "Welcome" title, "Sign in to Gundi" subtitle (login page only; other pages keep their own title under the mark)
  • keycloak/Dockerfile.kc11 / Dockerfile.kc26 — official image + theme folder, no RUN steps
  • keycloak/compose.theme-dev.yml + keycloak/tests/* — local 11+26 harness, smoke test (asserts every theme asset is served), cascade guard, headless-Chrome screenshots
  • .github/workflows/keycloak-theme.yml — builds both images, boots each with the dev realm, smoke-tests, and only then pushes to serca-artifact-registry/gundi/keycloak from main
  • keycloak/RUNBOOK.md — manual prod rollout (image swap → cdip-dev realm → cdip-prod realm), rollback, and the hand-off requirement for the 26 upgrade

Nothing deploys automatically. Prod Keycloak 11 is not ArgoCD-managed; rollout is a manual kubectl set image plus a realm setting.

Screenshots (local harness)

Before (the standard issue keycloak theme)

image

After (Gundi theme similar to EarthRanger ID)

image

Login, invalid-credentials, mobile and error pages on 11 and 26 are covered by keycloak/tests/screenshot.sh. Run keycloak/tests/screenshot.sh kc11 8081 against the compose harness to regenerate.

Notes for review

  • Keycloak 11 titles the login page with the same message key as the button, so 26 gets "Welcome" from loginAccountTitle while kc11.css hides the h1 text and draws "Welcome" in its place. Both are scoped with :has(#kc-form-login), so they apply on the login page only. The <title> tag reads "Sign in to Gundi" on both.
  • The submit button still reads "Log In" on 11 and "Sign In" on 26. EarthRanger uses "Continue"; aligning them is a follow-up on the ticket.
  • The 26 image must ship with the upgrade: the realm loginTheme setting lives in the DB and will already point at gundi after the DB copy.
  • The upgrade design doc (branch keycloak-26-upgrade-doc) still lists the theme as stock; update it there when it lands.

🤖 Generated with Claude Code

Chris Doehring and others added 14 commits September 22, 2026 19:35
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ripts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ages

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…d-off

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…status, smoke h1 assertion

- kc26.css: the parent theme fixes the login grid track at 34rem with no media
  query, so the card overflowed any viewport under ~576px. Track is now
  minmax(0, 34rem) and the phone gutter comes from container padding.
- RUNBOOK.md: Keycloak answers a bad redirect_uri with HTTP 400 on both 11 and
  26; the post-swap check now expects 400 instead of a -f'd 200.
- smoke.sh: assert the kc26 h1 itself reads 'Sign in to Gundi' (tolerating the
  dev-mode template comment) instead of a page-wide grep the <title> already
  satisfied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@chrisdoehring
chrisdoehring marked this pull request as ready for review September 23, 2026 03:51
Chris Doehring and others added 3 commits September 23, 2026 07:47
PatternFly 3 positions the alert icon absolutely and reserves room for it
with a 47px left padding. The theme's uniform 14px padding removed that
space, so the message rendered under the icon. The icon is now a flex item
sized to the text's line height, so padding stays uniform and wrapped lines
align under the first line.

The screenshot script gains a login-error shot: it submits bad credentials
with curl and renders the returned page, since that alert only exists on
the POST response and was never captured before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both Keycloak versions link img/favicon.ico through the theme's resource
path, so a file at that location shadows the stock Keycloak icon with no
template change. The ICO packs the portal's Gundi mark at 64, 48, 32 and
16px. The portal's own favicon.ico is the leftover React scaffold logo,
which browsers never show, so the icon is built from the mark PNG instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follows the EarthRanger login layout: the Gundi mark centred at the top of
the card, "Welcome" as the h1 and "Sign in to Gundi" beneath it. The realm
display-name header above the card is hidden and the wordmark dropped, as
the subtitle already names the product.

Each version draws the mark on its own card-header container in kc11.css
and kc26.css. Keycloak 26 gets the title from loginAccountTitle; Keycloak
11 titles the page with the button's doLogIn key, so kc11.css hides the h1
text and draws "Welcome" in its place. Title swap and subtitle are scoped
by the login form's presence, so other pages keep their own titles under
the mark. On 26 the header grid's column gap beside the empty locale-menu
column is zeroed so the title centres.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@amicavi amicavi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review (Claude Code), findings verified against the diff and CI runs. Headline: the kc26 CI leg is currently red — see the smoke.sh comment; one-line fix.

One finding that can't be anchored inline because the file is empty:

keycloak/themes/gundi/login/theme.properties is a committed zero-byte file (the compose bind-mount mountpoint). Any consumer that copies the theme directory whole — e.g. the RUNBOOK's KC26 hand-off allows "copy the theme folder into its own image" — ships a theme with no parent= and no styles=, rendering unstyled login pages with no error. Suggestion: make theme.properties the real KC11 file and keep only theme.kc26.properties as the variant, removing the trap and the rename COPY.

Comment thread keycloak/tests/smoke.sh Outdated
if [[ $version == kc26 ]]; then
# The h1 text follows a newline (and, in dev mode, a <!-- template: … --> comment) after the
# opening tag, so collapse newlines and allow whitespace/comments before matching.
tr -d '\n' <<<"$html" | grep -qE 'id="kc-page-title"[^>]*>([[:space:]]|<!--[^>]*-->)*Sign in to Gundi' \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI is red on the kc26 leg because of this assertion. Commit 6cde07d changed loginAccountTitle to "Welcome", but this still expects the h1 to read "Sign in to Gundi" (that text now only exists as CSS ::after content, which never appears in the HTML this grep reads). Latest run fails with exactly this fail message; the two runs before the Welcome commit were green. Fix: assert on "Welcome".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43bd4be. The kc26 assertion now expects the h1 to read "Welcome". Verified the smoke test passes locally against both versions.

Comment thread keycloak/tests/smoke.sh Outdated
done

# 2. Every /resources/ asset the page links must be served.
assets=$(grep -oE '(href|src)="[^"]*/resources/[^"]+"' <<<"$html" | sed -E 's/^(href|src)="//; s/"$//' | sort -u)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Under set -euo pipefail, if the page contains zero /resources/ links, grep exits 1 and kills the script during this assignment — so the "no /resources/ links found" diagnostic on the next line is unreachable. Line 40's refs= pipeline already guards with || true; this one needs the same.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43bd4be. The assets= pipeline now ends in || true like the refs= one, so an empty page reaches the "no /resources/ links found" diagnostic.

/* Login page title. Keycloak 11 titles the login page with doLogIn, the same key as the submit
button, so the h1 text is hidden and "Welcome" drawn in its place. Keycloak 26 has its own key
(loginAccountTitle in messages_en.properties). */
body:has(#kc-form-login) .login-pf-page #kc-page-title {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Welcome" title swap (here) and the "Sign in to Gundi" subtitle (gundi.css:31) are gated on body:has(...). Browsers without :has() (Firefox <121 incl. ESR 115, Safari <15.4, Chrome <105) drop these rules with no fallback: the h1 shows the stock "Log In" and the subtitle vanishes — for exactly the field users least likely to be on a current browser. No test covers this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly addressed in 43bd4be. The subtitle selector is now :is(body[data-page-id="login-login"], body:has(#kc-form-login)) #kc-page-title::after. :is() takes a forgiving selector list, so a browser without :has() drops only the 11 branch and still applies the subtitle on 26, where the h1 is real text from loginAccountTitle.

On 11 there is no pure-CSS alternative: the body carries no page marker (kcBodyClass is theme-wide) and the h1 uses the button's doLogIn key, so :has() or a FreeMarker override are the only options, and the design ruled out template overrides. Without :has() the 11 page degrades to the stock "Log In" title with the rest of the theme intact. Given 11 is the outgoing version, I documented that in the CSS comment rather than adding a template override. Happy to revisit if the team wants the override.

}
/* Login page only: "Sign in to Gundi" subtitle under the h1. The login form's id exists on
both versions and only on the login page, so its presence identifies the page. */
body:has(#kc-form-login) #kc-page-title::after {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Title/subtitle copy injected as CSS generated content has two costs: screen readers compute the KC11 heading as roughly "Welcome Log In Sign in to Gundi" (font-size: 0 doesn't remove text from the accessibility tree, and pseudo-element content joins the accessible name), and browser auto-translate/find-in-page can't touch CSS strings. Also "Sign in to Gundi" is duplicated verbatim here and in messages_en.properties (loginTitle) with nothing tying them together — a later rebrand will update one and miss the other.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 43bd4be. On 11 the h1 now uses visibility: hidden with visibility: visible on the pseudo-elements, which removes "Log In" from the accessibility tree; the accessible name becomes "Welcome Sign in to Gundi". On 26 the title is real text from the message bundle. The subtitle remains CSS content on both, so auto-translate and find-in-page cannot reach it; that is the cost of the no-template-override design and is noted in the comment. The copy is now cross-referenced in both gundi.css and messages_en.properties.

Comment thread .github/workflows/keycloak-theme.yml Outdated
matrix:
include:
- version: kc11
keycloak: "11.0.2"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keycloak versions are pinned twice — here in the push-job matrix (image tag) and in each Dockerfile FROM — with nothing linking them, and the RUNBOOK's rollout keys off the tag's version prefix. A bump to Dockerfile.kc26 alone would push an image built on the new base but tagged 26.7-gundi-<sha>, which operators would deploy believing it matches the rehearsed base. Suggestion: derive the tag version from the Dockerfile FROM, or feed both from one ARG.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43bd4be. The vars job parses FROM quay.io/keycloak/keycloak:<tag> from each Dockerfile and the push job tags with needs.vars.outputs[matrix.version], so the matrix no longer carries its own version list. A Dockerfile without a Keycloak FROM tag fails the job.

Comment thread .github/workflows/keycloak-theme.yml Outdated
steps:
- uses: actions/checkout@v4
- id: vars
run: echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This job spins up a runner + full checkout solely for git rev-parse --short HEAD, and runs on every PR although its only consumer (push) is main-gated. sha=${GITHUB_SHA::7} gives the same value with no checkout (a run step is still needed — Actions expressions can't substring), and the job can take if: github.ref == 'refs/heads/main'.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43bd4be. vars now runs only on main, takes the sha from ${GITHUB_SHA::7}, and keeps a sparse checkout of keycloak/ because it now reads the Dockerfile FROM tags (see the tag-linkage thread).

Comment thread keycloak/RUNBOOK.md

```bash
docker compose -f keycloak/compose.theme-dev.yml up -d # KC 11 on :8081, KC 26 on :8082, admin/admin
keycloak/tests/wait-ready.sh http://localhost:8081/auth/realms/cdip-dev

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block waits for readiness only on KC11 (:8081) and then immediately smokes both ports. KC26 is typically still running --import-realm, so a developer pasting this verbatim gets "login page did not return 200" on :8082 and concludes the theme is broken. The CI workflow waits per port; add a second wait-ready.sh line for :8082 here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43bd4be. The local-dev block waits for both :8081 and :8082 before running the smoke tests.

Comment thread keycloak/tests/screenshot.sh Outdated
shot_login_error() {
local jar html action
jar=$(mktemp) && html="$out/$version-login-error.html"
action=$(curl -s -c "$jar" "$local_auth?client_id=cdip-kong-gateway&response_type=code&scope=openid&$ok_redirect" \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shot_login_error fails silently and leaks its mktemp cookie jar when the fetch or action-parse fails (curl -s without -f; the grep exiting 1 under pipefail aborts mid-function before the rm). Separately, the sed 's#<head>#…#' base-injection only matches a literal attribute-less <head> — a Keycloak bump that adds any attribute makes it a no-op, so every stylesheet 404s and the login-error visual gets signed off against a screenshot that never loaded the theme. Suggestions: -f on the curls, || fail around the action parse, a trap for the jar, and sed -E 's#<head([^>]*)>#…#'.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43bd4be. Both curls use -f, the action parse and the POST are wrapped in || fail, a RETURN EXIT trap removes the cookie jar and the temp page on every exit path, and the <base> injection is sed -E 's#<head([^>]*)>#...#' with a follow-up check that the tag was actually inserted. Verified against a dead port: it fails with a message and leaves no temp files.

@@ -0,0 +1,148 @@
{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixture is a manual copy of keycloak/cdip-dev-realm.json whose only regeneration recipe is a python3 heredoc in the plan markdown — no checked-in script, no CI guard against drift. Drift starts as soon as the base realm changes (a pending branch already adds a cdip-integrations client this fixture lacks), and the duplicated client secrets now have to be rotated in two files. Suggestion: check the generator in as keycloak/dev/generate-realm.py and point the RUNBOOK at it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 43bd4be. keycloak/dev/generate-realm.py is checked in and derives the fixture from cdip-dev-realm.json; --check exits 1 when the fixture is stale and runs as a CI step in the test job. The RUNBOOK points at it. The only drift so far was a trailing newline.

@victorljn-earthranger

Copy link
Copy Markdown
Contributor

Looks great ! <3

I will skip the testing until it is in the dev environment, but Im looking forward to this update 🙌

…11y, realm generator, tag linkage

- smoke.sh asserts the 26 login h1 reads "Welcome" (the CI failure) and guards
  the asset grep so an empty page reports its diagnostic instead of aborting.
- screenshot.sh fails loudly on fetch or parse errors, cleans its cookie jar and
  temp page on every exit path, and injects <base> into a <head> with attributes.
- The subtitle selector wraps the 26 page-id branch and the 11 :has() branch in
  :is(), so browsers without :has() still apply it on 26. On 11 the hidden h1
  text uses visibility: hidden so the accessible name is "Welcome Sign in to
  Gundi" rather than including "Log In". The shared copy is cross-referenced
  between gundi.css and messages_en.properties.
- theme.properties is now the real Keycloak 11 file, so copying the theme
  folder whole yields a working theme; theme.kc26.properties stays the variant.
- keycloak/dev/generate-realm.py derives the dev realm fixture from the base
  export; CI fails when the fixture is stale, and the RUNBOOK points at it.
- The workflow derives each image tag's version from the Dockerfile FROM tag,
  takes the short sha from GITHUB_SHA, and runs the vars job only on main.
- RUNBOOK local-dev block waits for both ports before smoking them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@chrisdoehring

Copy link
Copy Markdown
Contributor Author

Re the empty theme.properties finding from the review body: fixed in 43bd4be. theme.properties is now the real Keycloak 11 file (renamed from theme.kc11.properties), so copying the theme folder whole yields a working theme. theme.kc26.properties stays the variant that Dockerfile.kc26 and the compose file install over it. Both dev containers were recreated with the new mounts and pass the smoke test.

@chrisdoehring
chrisdoehring merged commit a4fe4ac into main Sep 28, 2026
5 checks passed
@chrisdoehring
chrisdoehring deleted the feature/keycloak-gundi-login-theme branch September 28, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants