Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/keycloak-drift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ on:
# The check reads keycloakify's bundled message set, so an upgrade of it changes the answer.
- 'theme/ui/package.json'
- 'theme/ui/yarn.lock'
- 'theme/ui/dev-server/**'
- 'theme/build.gradle'

pull_request:
paths:
Expand All @@ -38,6 +40,8 @@ on:
- '.github/workflows/keycloak-drift.yml'
- 'theme/ui/package.json'
- 'theme/ui/yarn.lock'
- 'theme/ui/dev-server/**'
- 'theme/build.gradle'

workflow_dispatch:

Expand Down Expand Up @@ -79,3 +83,30 @@ jobs:
- name: Check against Keycloak
working-directory: theme/ui
run: node scripts/check-keycloak-drift.mjs

# The image build skips downloadStockKeycloak so a release cannot fail on Maven Central being
# unreachable, which leaves nothing exercising the download or the renderer behind the dev
# server's stock pages. This job is that, and it belongs here for the same reason: this
# workflow is already the one allowed to depend on Maven Central.
stock-renderer:
name: Dev server stock renderer
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up JDK 21 and gradle cache
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'temurin'
java-version: '21'
cache: 'gradle'

- name: Download Keycloak's themes
run: ./gradlew downloadStockKeycloak

# Covered nowhere else: the image build skips the download this needs. The script is
# runnable by hand as well, which is the point of it being a script.
- name: Render a stock page
run: theme/ui/dev-server/check-stock-renderer.sh
5 changes: 4 additions & 1 deletion .github/workflows/keycloak.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,11 @@ jobs:
with:
node-version: '22'

# downloadStockKeycloak fetches Keycloak's own themes, FreeMarker and Jackson so the local dev
# server can render stock pages. None of it goes in the image, so CI skips it rather than
# downloading it on every run and failing a release if Maven Central is unreachable.
- name: Build with Gradle
run: ./gradlew installDist
run: ./gradlew installDist -x downloadStockKeycloak
Comment thread
Ekhorn marked this conversation as resolved.
Resolved

- name: Install QEMU
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
Expand Down
6 changes: 5 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,11 @@ Work from the link you were given: node ids change when the file is reorganized,
## Gradle

- The jar is assembled from two resource roots, so anything under `src/main/resources/theme/openremote/login` collides with the generated theme.
- The Dockerfile's `ARG VERSION` is the Keycloak the image runs; the version catalog's `keycloak` is what the event listener compiles against.
- The Dockerfile's `ARG VERSION` is the Keycloak the image runs; the version catalog's `keycloak` is what the event listener compiles against. `downloadStockKeycloak` reads the Dockerfile.

## Dev server

- `dev-server/StockRenderer.java` renders Keycloak's own templates from Keycloakify's mocks. `keycloakBehavior()` adapts the mock data to what `FreeMarkerLoginFormsProvider` provides; add a rule only for a template that fails to render, and never one that names a page.

## Verifying

Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
[versions]
autoService = "1.1.1"
byteBuddy = "1.17.5"
jackson = "2.22.2"
jbossLogging = "3.6.1.Final"
keycloak = "26.6.4"
lombok = "1.18.38"
Expand All @@ -10,6 +11,7 @@ slf4j = "2.0.17"
autoService = { module = "com.google.auto.service:auto-service", version.ref = "autoService" }
byteBuddy = { module = "net.bytebuddy:byte-buddy", version.ref = "byteBuddy" }
byteBuddyAgent = { module = "net.bytebuddy:byte-buddy-agent", version.ref = "byteBuddy" }
jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
jbossLogging = { module = "org.jboss.logging:jboss-logging", version.ref = "jbossLogging" }
keycloakCore = { module = "org.keycloak:keycloak-core", version.ref = "keycloak" }
keycloakServerSpi = { module = "org.keycloak:keycloak-server-spi", version.ref = "keycloak" }
Expand Down
92 changes: 92 additions & 0 deletions theme/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,99 @@ sourceSets.main.resources.srcDir unpackedThemeDir

processResources.dependsOn 'unpackLoginTheme'

// ---------------------------------------------------------------------------
// Stock Keycloak themes, for the dev server only
//
// ui/dev-server renders Keycloak's own login templates from the dev harness's mock data, so a
// stock page can be shown beside ours, and an unimplemented page previewed, without running
// Keycloak. This downloads those templates, and the FreeMarker that renders them, into
// ui/.keycloak (gitignored). Nothing here reaches the jar.
// ---------------------------------------------------------------------------

repositories {
mavenCentral()
}

/*
* The Keycloak the image actually runs, taken from the Dockerfile's ARG VERSION, because the
* pages to compare against are the ones that version serves. The version catalog's keycloak
* entry is the SPI this repo compiles against, and is not necessarily the same.
*/
def keycloakImageVersion = providers.fileContents(rootProject.layout.projectDirectory.file('Dockerfile'))
.asText
.map { dockerfile ->
def match = dockerfile =~ /(?m)^ARG VERSION=(\S+)\s*$/
if (!match.find()) {
throw new GradleException('No `ARG VERSION=` line in the Dockerfile')
}
match.group(1)
}
.get()

configurations {
stockKeycloakThemes { canBeConsumed = false; transitive = false }
stockKeycloakVendor { canBeConsumed = false; transitive = false }
stockRenderer { canBeConsumed = false }
}

dependencies {
stockKeycloakThemes "org.keycloak:keycloak-themes:${keycloakImageVersion}"
// PatternFly and the other assets the themes import live in a jar of their own.
stockKeycloakVendor "org.keycloak:keycloak-themes-vendor:${keycloakImageVersion}"
// Keycloak's parent pom pins the FreeMarker it renders with, so this follows it on upgrade.
stockRenderer platform("org.keycloak:keycloak-parent:${keycloakImageVersion}")
stockRenderer 'org.freemarker:freemarker'
// Only to read the dev server's own protocol, so it follows this repo rather than Keycloak.
stockRenderer libs.jacksonDatabind
}

def stockKeycloakManifest = layout.buildDirectory.file('stock-keycloak/stock.json')

tasks.register('writeStockKeycloakManifest') {
description = 'Records which Keycloak the downloaded stock themes belong to'
group = 'build'

def version = keycloakImageVersion
def manifest = stockKeycloakManifest

inputs.property('keycloakVersion', version)
outputs.file(manifest)

doLast {
manifest.get().asFile.text = groovy.json.JsonOutput.prettyPrint(
groovy.json.JsonOutput.toJson([keycloakVersion: version]))
}
}

tasks.register('downloadStockKeycloak', Sync) {
description = "Downloads Keycloak's own login themes for the dev server's stock pages"
group = 'build'

into file('ui/.keycloak')

// The two themes worth rendering: keycloak.v2, what a fresh realm uses, and keycloak, what
// the built theme's parent= points at for the pages this theme does not implement, plus
// base, which both extend.
from(zipTree(configurations.stockKeycloakThemes.elements.map { it.first().asFile })) {
include 'theme/base/login/**', 'theme/keycloak/login/**', 'theme/keycloak.v2/login/**', 'theme/keycloak/common/**'
}
from(zipTree(configurations.stockKeycloakVendor.elements.map { it.first().asFile })) {
include 'theme/keycloak/common/**'
}
// The renderer's classpath, as a directory: it is passed to java as `lib/*`, so which jars are
// in it is the resolved graph's business and nothing here has to name them.
from(configurations.stockRenderer) {
into 'lib'
}
from(tasks.named('writeStockKeycloakManifest'))

includeEmptyDirs = false
}

tasks.register('installDist', Copy) {
// Not part of the image: only so the dev server's stock pages work after the usual build.
dependsOn 'downloadStockKeycloak'

into "${rootDir}/build/image"
from jar.outputs
}
2 changes: 2 additions & 0 deletions theme/ui/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ node_modules/
dist/
build_keycloak/
src/kc.gen.tsx
# Keycloak's own themes and the renderer's jars, downloaded by `./gradlew installDist`.
.keycloak/

# Keycloak's themes jar and its contents, downloaded by `yarn check-keycloak`.
.keycloak-cache/
Expand Down
51 changes: 33 additions & 18 deletions theme/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,9 @@ yarn start # http://localhost:5173

No Keycloak, no container. Page data comes from Keycloakify's `getKcContextMock`, so every page has realistic values, and rspack live-reloads on save.

A rail down the left lists **all 38 login pages** in two sections: the ones with an
implementation, then the ones a built theme leaves to Keycloak's own (clicking those shows a
placeholder). It overlays rather than displacing the page, so the layout you are judging is the
real one; below 940px it collapses to a tab at the left edge. It also carries brand color and
logo overrides, for checking a custom project's branding without a manager running.

Switching pages **does not reload the document** — the click is handled in place, the URL is
updated with `pushState` and Lit re-renders. A real navigation re-parsed the bundle and, since
rspack injects CSS through JS in development, repainted an unstyled frame each time, which is
what made switching flash. Back/forward work via `popstate`, and `index.html` resolves the color
scheme inline before first paint so a dark page never flashes light on hard reload.
Live reload covers `src/` only. `rspack.config.mjs` and the modules it imports — everything under `dev-server/` — are read once at startup, so a change there needs the dev server restarted. Nothing there is part of the built theme, so it never needs a Gradle build.

A menu down the left side lists **every login page** in two sections: the ones with an implementation, then the ones a built theme leaves to Keycloak's own (clicking those renders Keycloak's page from the same mock data). It overlays rather than displacing the page, so the layout you are judging is the real one; below 940px it collapses to a tab at the left edge. It also carries brand color and logo overrides, for checking a custom project's branding without a manager running.

Switching pages **does not reload the document** — the click is handled in place, the URL is updated with `pushState` and Lit re-renders. A real navigation re-parsed the bundle and, since rspack injects CSS through JS in development, repainted an unstyled frame each time, which is what made switching flash. Back/forward work via `popstate`, and `index.html` resolves the color scheme inline before first paint so a dark page never flashes light on hard reload.

Expand All @@ -41,17 +33,40 @@ Pages, settings and color scheme are all addressable directly, `settings` being

```
http://localhost:5173/?page=login-config-totp.ftl
http://localhost:5173/?page=login-config-totp.ftl&settings=totp.manual
http://localhost:5173/?page=login.ftl&settings=realm.rememberMe,realm.registrationAllowed
http://localhost:5173/?page=login.ftl&settings=social.providers,message.error
http://localhost:5173/?page=login.ftl&lang=de
http://localhost:5173/?page=login.ftl&theme=dark
http://localhost:5173/?page=login.ftl&theme=light
```

`theme` is an explicit override in **both** directions; with no `theme` parameter the page
follows `prefers-color-scheme`.
**Links and buttons on the pages go where Keycloak would go.** In production every one of them returns to Keycloak, and its authentication flow on the server decides what comes next — so without a server, every submit used to post to a URL the dev server does not serve, and the language dropdown opened a GitHub gist. `src/dev/flows.ts` replaces each navigation URL in the mock with a marker naming its field, and resolves it, together with the submit button's name and value, against a table of Keycloak's transitions: "Review profile" goes to `idp-review-user-profile`, "Forgot password" back to login with the email-sent message, "Try Another Way" to `select-authenticator`, the language dropdown to the same page in that language.

Where Keycloak's answer depends on the realm or the data — does "Sign In" succeed, does the user have 2FA, can the realm send email — a small panel offers each outcome instead of picking one, and a step that ends the login flow says so rather than navigating nowhere. That table is the one place the harness has to know Keycloak's flow, because the mocks do not carry it; every destination in it is checked against the mocks' page ids, and a control with no entry says so on screen instead of silently doing nothing.

`theme` is an explicit override in **both** directions. With no `theme` parameter the page is light: following `prefers-color-scheme` is switched off in `src/render.ts` until the manager has a dark mode of its own.

**Nothing in that nav is hardcoded.** Both halves are derived: every pageId comes from `kcContextMocks`, the same data `getKcContextMock` serves, so a Keycloakify upgrade that adds or removes a page updates the list automatically; and the implemented set comes from `src/page-registry.ts`, which scans `src/pages` with rspack's `import.meta.webpackContext`.

## Comparing against stock Keycloak

**Show stock Keycloak** in the menu splits the window: this theme on the left, Keycloak's own page on the right. Useful for deciding whether a page is worth implementing, and more so now that the theme uses Keycloak's own wording — the copy on the left is supposed to be the copy on the right.

No Keycloak, no container. The dev server renders Keycloak's own FreeMarker templates itself, from the same mock data the left side is using — so **the realm settings in the menu apply to both sides**, and every page Keycloakify has a mock for is one click away. It needs two things:

- `./gradlew installDist` to have run once. It downloads the templates for the Keycloak version in the Dockerfile, plus the renderer's classpath — the FreeMarker that Keycloak release uses, and Jackson — into `.keycloak/` (gitignored). `./gradlew :theme:downloadStockKeycloak` does just that part.
- `java` on the PATH — the JDK Gradle already needs. The renderer is one file, `dev-server/StockRenderer.java`, run by the JDK's single-file launcher, so there is nothing to build. If either is missing the pane says which.

The right side is Keycloak's `keycloak.v2` theme, which is what a fresh realm uses. Its links and buttons go through the same dev flows as ours (`src/dev/flows.ts`), so clicking "Forgot Password?" on either side moves both on.

**Pages this theme does not implement render the same way, in place of a placeholder**, in the theme production actually serves them from. The dev server does not name that theme: it reads `parent=` out of the descriptor the build ships, so the preview cannot drift from the image. That is `keycloak.v2` by default, the same theme as the compare pane; `./gradlew installDist -PloginParentTheme=keycloak` switches the inherited pages to Keycloak's older theme, and the preview follows.

The settings list is the union of what changes our page and what changes either stock page, so a setting only Keycloak reacts to is not hidden while you compare. Half of that answer arrives from the dev server a moment after the click, so until it does the list keeps whatever the page offered last time rather than collapsing to what the browser alone can work out and reopening.

What this is not: a running server. The pages are Keycloak's markup with mock data, so behavior that only exists server-side — real validation, a real redirect — is not there. Two templates need more than their mocks provide (`webauthn-register`, `login-recovery-authn-code-config`); they say so rather than rendering blank.

**Nothing in that nav is hardcoded.** Both halves are derived: every pageId comes from
`kcContextMocks`, the same data `getKcContextMock` serves, so a Keycloakify upgrade that adds or
removes a page updates the list automatically; and the implemented set comes from
`src/page-registry.ts`, which scans `src/pages` with rspack's `import.meta.webpackContext`.
Keycloakify's mocks follow Keycloak's template data closely but not exactly, so the renderer fills the gap the way Keycloak's own FreeMarker provider does — see `keycloakBehavior()` in `StockRenderer.java`. Each rule there was found as a template failing to render, and none names a page, so an upgrade that changes one shows up as a render error rather than a wrong page.

## Adding a page

Expand Down Expand Up @@ -102,7 +117,7 @@ Four further failures are about the check rather than the theme. It reads upstre

What it watches is our pages' upstream counterparts closed over `<#import>`, not just the page files: much of what a page renders lives in a macro it imports, which can change while the page file does not.

It needs `unzip` or a JDK, and downloads Keycloak's themes jar once per version into `.keycloak-cache/`, checked against the checksum Maven Central publishes beside it.
It needs `unzip` or a JDK, and downloads Keycloak's themes jar once per version into `.keycloak-cache/`, checked against the checksum Maven Central publishes beside it. When `./gradlew installDist` has already unpacked the same version into `.keycloak/`, with every theme the check reads, it reads that instead.

Which of Keycloak's own themes our pages are compared against is the one they would fall through to: `loginParentTheme` from `theme/build.gradle`, which is `keycloak.v2` unless the build is given another, plus `base` for every file that theme does not override. To check against a different one, set `ORG_GRADLE_PROJECT_loginParentTheme`, the environment variable Gradle also reads for that property. A `-PloginParentTheme` on a Gradle command line, or an entry in a `gradle.properties`, is not visible to the check.

Expand Down
Loading
Loading