From 9b213dfde6dfe8f242832025a79005d7e91738b1 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Tue, 4 Aug 2026 13:25:25 +0200 Subject: [PATCH 1/9] Migrate login pages to Vaadin using UMD modules --- .gitignore | 4 + README.md | 116 + theme/build.gradle | 85 + theme/dev/build-design-system.sh | 69 + theme/dev/preview.html | 587 ++++ theme/dev/serve.js | 122 + .../theme/openremote/login/error.ftl | 17 +- .../theme/openremote/login/field.ftl | 109 + .../openremote/login/login-config-totp.ftl | 171 +- .../theme/openremote/login/login-otp.ftl | 73 +- .../openremote/login/login-reset-password.ftl | 49 +- .../login/login-update-password.ftl | 80 +- .../theme/openremote/login/login.ftl | 124 +- .../login/messages/messages_en.properties | 30 +- .../theme/openremote/login/register.ftl | 86 +- .../resources/css/MaterialIcons-Regular.eot | Bin 143258 -> 0 bytes .../resources/css/MaterialIcons-Regular.ijmap | 1 - .../resources/css/MaterialIcons-Regular.svg | 2373 ----------------- .../resources/css/MaterialIcons-Regular.ttf | Bin 128180 -> 0 bytes .../resources/css/MaterialIcons-Regular.woff | Bin 57620 -> 0 bytes .../resources/css/MaterialIcons-Regular.woff2 | Bin 44300 -> 0 bytes .../openremote/login/resources/css/login.css | 662 +++++ .../login/resources/css/materialize.min.css | 13 - .../openremote/login/resources/css/styles.css | 153 -- .../openremote/login/resources/img/logo.svg | 30 + .../openremote/login/resources/js/branding.js | 184 ++ .../openremote/login/resources/js/forms.js | 66 + .../login/resources/js/materialize.min.js | 6 - .../theme/openremote/login/template.ftl | 329 ++- .../theme/openremote/login/theme.properties | 28 +- 30 files changed, 2538 insertions(+), 3029 deletions(-) create mode 100755 theme/dev/build-design-system.sh create mode 100644 theme/dev/preview.html create mode 100644 theme/dev/serve.js create mode 100644 theme/src/main/resources/theme/openremote/login/field.ftl delete mode 100644 theme/src/main/resources/theme/openremote/login/resources/css/MaterialIcons-Regular.eot delete mode 100644 theme/src/main/resources/theme/openremote/login/resources/css/MaterialIcons-Regular.ijmap delete mode 100644 theme/src/main/resources/theme/openremote/login/resources/css/MaterialIcons-Regular.svg delete mode 100644 theme/src/main/resources/theme/openremote/login/resources/css/MaterialIcons-Regular.ttf delete mode 100644 theme/src/main/resources/theme/openremote/login/resources/css/MaterialIcons-Regular.woff delete mode 100644 theme/src/main/resources/theme/openremote/login/resources/css/MaterialIcons-Regular.woff2 create mode 100644 theme/src/main/resources/theme/openremote/login/resources/css/login.css delete mode 100644 theme/src/main/resources/theme/openremote/login/resources/css/materialize.min.css delete mode 100644 theme/src/main/resources/theme/openremote/login/resources/css/styles.css create mode 100644 theme/src/main/resources/theme/openremote/login/resources/img/logo.svg create mode 100644 theme/src/main/resources/theme/openremote/login/resources/js/branding.js create mode 100644 theme/src/main/resources/theme/openremote/login/resources/js/forms.js delete mode 100644 theme/src/main/resources/theme/openremote/login/resources/js/materialize.min.js diff --git a/.gitignore b/.gitignore index f281dca..69e30e4 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,10 @@ manager/.factorypath/ Pods/ node_modules/ +# OpenRemote design system bundles, downloaded from npm by the theme build +# (see theme/build.gradle, version pinned by orUiVersion in login/theme.properties) +theme/src/main/resources/theme/openremote/login/resources/vendor/ + # Specific files and logs openremote.log dive.log diff --git a/README.md b/README.md index f70145f..25f83e4 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,119 @@ To get the standard themes for reference use the following (replace `${VERSION}` ```shell docker cp ID:/opt/keycloak/lib/lib/main/org.keycloak.keycloak-themes-${VERSION}.jar ./ ``` + +### Design system + +The login theme renders [`@openremote/or-vaadin-components`](https://www.npmjs.com/package/@openremote/or-vaadin-components) +styled by [`@openremote/theme`](https://www.npmjs.com/package/@openremote/theme). Both are consumed as +**prebuilt bundles from npm**, so this repository has no JavaScript toolchain. `./gradlew :theme:downloadDesignSystem` +fetches them into `login/resources/vendor/` (gitignored); it runs automatically as part of the build. + +To upgrade the design system, change `orUiVersion` in +`theme/src/main/resources/theme/openremote/login/theme.properties`. That single value pins the +download *and* is used as a cache-busting query string on the asset URLs — which matters because +`url.resourcesPath` only changes between Keycloak releases, so without it browsers, mobile apps and +desktop apps would keep serving the previous theme from cache. + +#### Building the design system locally (temporary) + +The npm download does not work yet, because neither package publishes a usable bundle: + +* `@openremote/or-vaadin-components` declares `"main": "build/dist/umd/index.bundle.js"` and now has + an rspack config plus a `prepack` that builds it, but no release carrying that artifact has been + published. +* `@openremote/theme`'s rspack config points at `src/index.ts` while the package ships + `src/index.js`, so it has never produced a bundle and has no `prepack`. + +Until both are fixed upstream, build the assets from a local `openremote` checkout: + +```shell +theme/dev/build-design-system.sh ../openremote # writes into login/resources/vendor +./gradlew installDist -PorUiLocal # skips the npm download +``` + +`-PorUiLocal` tells the build to use whatever is already in `resources/vendor`. Once the packages +publish their bundles, delete `theme/dev/build-design-system.sh` and drop the flag. + +Note that the script also swaps Inter's 18 static faces (2.1 MB) for the variable latin subset +(~207 KB) and maps `--lumo-font-family` onto `InterVariable`; the upstream bundle should do the same. + +### Previewing pages without Keycloak + +`theme/dev/serve.js` is a zero-dependency dev server with live reload — no container, no install: + +```shell +node theme/dev/serve.js # http://localhost:8000/dev/preview.html +``` + +Editing any `.ftl`, `.css` or `.js` under the theme reloads the browser. The preview shows one page +at a time at **true full viewport**, so the centring and the `100vh` layout are representative. +State lives in the URL and survives reloads, so you stay where you were: + +``` +/dev/preview.html?page=totp-setup&dark=1&brand=e8730a +``` + +Pages are `login`, `reset`, `register`, `totp-setup`, `otp`. For narrow widths use the browser's +device toolbar rather than a fake frame, so the viewport — and therefore the media queries — are +genuinely narrow. Add `?noreload` when driving the page from screenshot tooling; the live-reload +stream never closes, which stops headless browsers deciding the page is idle. + +It must be **served**, not opened as a file. Vaadin applies its Lumo styles by reading +`document.styleSheets[].cssRules`, which throws on `file://` because each file is an opaque origin — +the page still renders, but every component silently falls back to Vaadin's unstyled base look, +which reads as a CSS bug rather than a loading one. + +The preview does not execute the FreeMarker templates and its markup is a hand-maintained copy, so +verify real pages against a running Keycloak. + +### Testing the real pages without building this image + +You do not need to build `openremote/keycloak` to work on the theme — mount it into the stock +Keycloak image instead: + +```shell +docker run --rm -p 8081:8080 \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \ + -v "$PWD/theme/src/main/resources/theme/openremote:/opt/keycloak/themes/openremote:ro" \ + keycloak/keycloak:26.7.0 start-dev \ + --spi-theme-cache-themes=false --spi-theme-cache-templates=false --spi-theme-static-max-age=-1 +``` + +Then set the realm's **login theme** to `openremote` in the admin console (Realm settings → Themes). +Set only the login theme: `--spi-theme-default=openremote` makes Keycloak look for an *admin* theme +of that name too, which this theme does not provide, and the admin console then fails to load. + +With template caching off, `.ftl` edits show up on refresh. + +Pages that are awkward to reach by driving a flow can be deep-linked with +[application-initiated actions](https://www.keycloak.org/docs/latest/server_admin/#con-aia_server_administration_guide), +appending `kc_action` to the auth request: `CONFIGURE_TOTP`, `UPDATE_PASSWORD`, `UPDATE_PROFILE`, +`UPDATE_EMAIL`, `VERIFY_EMAIL`. + +### Branding + +Logo, application title, favicon and brand colour are read at runtime from the manager's +`manager_config.json`, via the public `GET /api/{realm}/configuration/manager` endpoint. A custom +project therefore only needs its own manager config — **no changes to this theme**. Legacy configs +are handled too: `styles` strings written for the Manager's shadow DOM (`:host > *`) are rewritten to +`:root`, and the legacy `--or-app-color4` is mapped onto `--or-color-primary`. An explicit +`--or-color-*` in the config takes precedence. + +`managerUrl` in `login/theme.properties` is empty by default, which means a same-origin request. +That is correct in production: Keycloak needs a fixed hostname, and that hostname is also one of the +manager's domains (the proxy serves the manager at `/` and Keycloak at `/auth`), so the login page +always renders somewhere a manager answers. Because branding is keyed on realm rather than domain, +it does not matter which manager domain the user originally came from. + +Set `managerUrl` only when Keycloak is served from an origin with no manager behind it — including a +split-origin development setup: + +```properties +# theme/src/main/resources/theme/openremote/login/theme.properties +managerUrl=http://127.0.0.1:8080 +``` + +With the bind mount above this takes effect on the next page load, no rebuild required. Such a setup +also needs the Keycloak origin added to `OR_WEBSERVER_ALLOWED_ORIGINS` on the manager, since +production CORS is not `*` (in dev mode the manager already allows all origins). diff --git a/theme/build.gradle b/theme/build.gradle index 369f20e..813a320 100644 --- a/theme/build.gradle +++ b/theme/build.gradle @@ -6,6 +6,91 @@ base { archivesName = "openremote-theme-provider" } +// --------------------------------------------------------------------------- +// OpenRemote design system +// +// The login theme renders @openremote/or-vaadin-components, styled by @openremote/theme. +// Both are consumed as prebuilt bundles published to npm, so this repository needs no +// JavaScript toolchain at all: upgrading the design system is a one-line change to +// orUiVersion in login/theme.properties. +// +// The download writes straight into the theme's own resources directory (gitignored) so +// that a plain `./gradlew installDist` produces a complete jar AND the bind-mounted +// development flow in the README picks the assets up without a separate build step. +// --------------------------------------------------------------------------- + +def loginThemeDir = file('src/main/resources/theme/openremote/login') +def vendorDir = new File(loginThemeDir, 'resources/vendor') + +// theme.properties is the single source of truth for the version, so the value the +// templates use for cache-busting can never drift from the bundles actually shipped. +def designSystemVersion = { + def props = new Properties() + new File(loginThemeDir, 'theme.properties').withInputStream { props.load(it) } + def version = props.getProperty('orUiVersion') + if (!version) { + throw new GradleException('orUiVersion is not set in login/theme.properties') + } + return version +} + +// Escape hatch while the bundles are not yet published: build them from a local openremote +// checkout with theme/dev/build-design-system.sh and pass -PorUiLocal so the download is +// skipped and the existing vendor directory is used as-is. Remove once the packages ship +// bundles. Resolved here at configuration time; reading `project` from inside a task action +// is not allowed by the configuration cache. +def useLocalDesignSystem = project.hasProperty('orUiLocal') + +tasks.register('downloadDesignSystem') { + description = 'Downloads the prebuilt OpenRemote design system bundles used by the login theme' + group = 'build' + + onlyIf { !useLocalDesignSystem } + + def version = designSystemVersion() + + // Keyed on the version so Gradle skips the download on repeat builds, and redoes it + // as soon as orUiVersion changes. + inputs.property('orUiVersion', version) + outputs.dir(vendorDir) + + doLast { + // Artifact -> local filename. The font files are emitted next to the stylesheet by + // @openremote/theme's bundle and are referenced from it by relative URL, so their + // paths here must mirror the ones inside or-theme.css. + def artifacts = [ + "@openremote/or-vaadin-components@${version}/dist/umd/index.bundle.js": 'or-vaadin.js', + "@openremote/theme@${version}/dist/index.css" : 'or-theme.css', + "@openremote/theme@${version}/dist/variable-latin/InterVariable-subset.woff2": + 'variable-latin/InterVariable-subset.woff2', + "@openremote/theme@${version}/dist/variable-latin/InterVariable-Italic-subset.woff2": + 'variable-latin/InterVariable-Italic-subset.woff2' + ] + + vendorDir.mkdirs() + + artifacts.each { specifier, target -> + def destination = new File(vendorDir, target) + destination.parentFile.mkdirs() + + def url = "https://unpkg.com/${specifier}" + logger.lifecycle("Fetching ${url}") + + try { + new URI(url).toURL().withInputStream { input -> + destination.withOutputStream { output -> output << input } + } + } catch (IOException e) { + throw new GradleException( + "Could not download ${url}. The login theme needs the prebuilt design " + + "system bundles; check that version ${version} has been published.", e) + } + } + } +} + +processResources.dependsOn downloadDesignSystem + tasks.register('installDist', Copy) { into "${rootDir}/build/image" from jar.outputs diff --git a/theme/dev/build-design-system.sh b/theme/dev/build-design-system.sh new file mode 100755 index 0000000..711ee02 --- /dev/null +++ b/theme/dev/build-design-system.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Build the OpenRemote design system assets from a local openremote checkout and install +# them into the login theme's vendor directory. +# +# TEMPORARY BRIDGE. The intended source of these files is npm — see +# theme/build.gradle, which downloads them for the version pinned by orUiVersion in +# login/theme.properties. That path does not work yet because: +# +# * @openremote/or-vaadin-components declares "main": "build/dist/umd/index.bundle.js" +# and now has an rspack config and a prepack that builds it, but no release carrying +# that artifact has been published. +# * @openremote/theme has an rspack config that points at src/index.ts while the package +# actually ships src/index.js, so it has never produced a bundle and has no prepack. +# Until that is fixed upstream the CSS is flattened here with esbuild instead. +# +# Once both packages publish their bundles, delete this script and drop the -PorUiLocal +# flag; nothing else in the repository depends on it. +# +# Usage: +# theme/dev/build-design-system.sh [path-to-openremote-checkout] +# ./gradlew installDist -PorUiLocal # skips the npm download +# +set -euo pipefail + +OR_REPO="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/../openremote}" +THEME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VENDOR="$THEME_DIR/src/main/resources/theme/openremote/login/resources/vendor" + +if [ ! -d "$OR_REPO/ui/component/or-vaadin-components" ]; then + echo "error: no openremote checkout at $OR_REPO" >&2 + echo "usage: $0 [path-to-openremote-checkout]" >&2 + exit 1 +fi + +command -v node >/dev/null || { echo "error: node is not on PATH" >&2; exit 1; } + +RSPACK="$OR_REPO/node_modules/@rspack/cli/bin/rspack.js" +ESBUILD="$OR_REPO/node_modules/.bin/esbuild" +[ -f "$RSPACK" ] || { echo "error: $RSPACK not found; run yarn install in $OR_REPO" >&2; exit 1; } + +echo "==> Building or-vaadin-components UMD bundle" +cd "$OR_REPO/ui/component/or-vaadin-components" +node "$RSPACK" build >/dev/null + +echo "==> Flattening @openremote/theme CSS" +cd "$OR_REPO/ui/component/theme" +mkdir -p "$VENDOR/fonts" +# --alias swaps the 18 static Inter faces (2.1MB) for the variable latin subset (~207KB). +node "$ESBUILD" src/index.css \ + --bundle \ + --outfile="$VENDOR/or-theme.css" \ + --alias:inter-ui/inter.css=inter-ui/inter-variable-latin.css \ + --loader:.woff2=file \ + --asset-names="fonts/[name]" \ + --log-level=error + +# default.css asks for the "Inter" family; the variable build registers "InterVariable". +cat >> "$VENDOR/or-theme.css" <<'CSS' + +/* Appended by build-design-system.sh: the variable font registers as "InterVariable" + while default.css requests "Inter". Kept out of the upstream token on purpose. */ +:root { --lumo-font-family: "InterVariable", "Inter", -apple-system, BlinkMacSystemFont, "Roboto", "Segoe UI", Helvetica, Arial, sans-serif; } +CSS + +cp "$OR_REPO/ui/component/or-vaadin-components/build/dist/umd/index.bundle.js" "$VENDOR/or-vaadin.js" + +echo "==> Installed into $VENDOR" +du -ch "$VENDOR"/or-vaadin.js "$VENDOR"/or-theme.css "$VENDOR"/fonts/* | tail -1 diff --git a/theme/dev/preview.html b/theme/dev/preview.html new file mode 100644 index 0000000..cfacd86 --- /dev/null +++ b/theme/dev/preview.html @@ -0,0 +1,587 @@ + + + + + + + OpenRemote login theme preview + + + + + + + + + + + + + + + + +
+
+ +
+

Log in to your account

+
+
+ + + + + + + + + + + + + +
+
+ Log in + +
+

Forgot password?

+
+
+
+
+
+
+ + +
+
+ +
+ +

Back to login

+
+

Password recovery

+

Enter your username or email address and we will send you instructions on how to create a new password.

+
+
+
+ + + + +
+ Submit + +
+
+
+
+
+
+ + +
+
+ +
+

Back to login

+
+

Register new account

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ Register + +
+
+
+
+
+
+ + +
+
+ +
+

Back to login

+
+

Mobile Authenticator Setup

+ +
+
    +
  1. +

    Install one of the following applications on your mobile:

    +
    • Microsoft Authenticator
    • FreeOTP
    • Google Authenticator
    +
  2. +
  3. Open the application and scan the barcode:

  4. +
+
+ + QR code + Unable to scan? +
+
    +
  1. Enter the one-time code provided by the application and click Submit to finish the setup.

  2. +
  3. Provide a Device Name to help you manage your OTP devices.

  4. +
+
+
+
+
+ + + + + + + + +
+ Submit + +
+
+
+
+
+
+ + +
+
+ +
+
+

Update password

+
+
+
+ + + + + + + + + + + + + +
+
+ Submit + +
+
+ Cancel + +
+
+
+
+
+
+
+ + +
+
+ +
+
+

We are sorry...

+
+
+ + +

Back to application

+
+
+
+
+ + +
+
+ +
+

Back to login credentials

+
+

Log in to your account

+
+
+
+ + + + + + + + + + + + + + + +
+ Log in + +
+
+
+
+
+
+ + + + diff --git a/theme/dev/serve.js b/theme/dev/serve.js new file mode 100644 index 0000000..90c2d19 --- /dev/null +++ b/theme/dev/serve.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/* + * Zero-dependency dev server with live reload for the login theme preview. + * + * node theme/dev/serve.js # http://localhost:8000/dev/preview.html + * PORT=9000 node theme/dev/serve.js + * + * Why a server at all, rather than opening preview.html directly: Vaadin applies its Lumo + * styles by reading document.styleSheets[].cssRules, which throws on file:// because every + * file is its own opaque origin. The page still renders, but every component silently falls + * back to Vaadin's unstyled base look - which reads as a CSS bug rather than a loading one. + * + * Edits to any .ftl, .css, .js or .html under the theme trigger a browser reload. + */ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); + +const ROOT = path.resolve(__dirname, ".."); +const LOGIN = path.join(ROOT, "src/main/resources/theme/openremote/login"); +const PORT = Number(process.env.PORT) || 8000; + +const MIME = { + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", + ".json": "application/json", +}; + +const RELOAD_SNIPPET = ` +`; + +const clients = new Set(); + +function notify(file) { + process.stdout.write(` changed: ${path.relative(ROOT, file)} -> reload\n`); + for (const res of clients) { + res.write("data: reload\n\n"); + } +} + +let pending = null; +function scheduleNotify(file) { + clearTimeout(pending); + pending = setTimeout(() => notify(file), 60); +} + +for (const dir of [path.join(ROOT, "dev"), LOGIN]) { + try { + fs.watch(dir, { recursive: true }, (_event, name) => { + if (name && /\.(ftl|css|js|html|svg|properties)$/.test(name)) { + scheduleNotify(path.join(dir, name)); + } + }); + } catch (err) { + console.warn(`warning: cannot watch ${dir} (${err.code}); live reload disabled there`); + } +} + +http + .createServer((req, res) => { + const urlPath = decodeURIComponent(req.url.split("?")[0]); + + if (urlPath === "/__reload") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + res.write("retry: 500\n\n"); + clients.add(res); + req.on("close", () => clients.delete(res)); + return; + } + + // ?noreload skips the live-reload injection. The SSE connection never closes, which + // stops headless screenshot tooling from ever deciding the page is idle. + const noReload = /[?&]noreload\b/.test(req.url); + const target = urlPath === "/" ? "/dev/preview.html" : urlPath; + const file = path.join(ROOT, target); + + // Keep the server inside the theme directory. + if (!file.startsWith(ROOT)) { + res.writeHead(403).end("forbidden"); + return; + } + + fs.readFile(file, (err, data) => { + if (err) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end(`not found: ${target}\n`); + return; + } + const ext = path.extname(file); + const body = ext === ".html" && !noReload ? data + RELOAD_SNIPPET : data; + res.writeHead(200, { + "Content-Type": MIME[ext] || "application/octet-stream", + "Cache-Control": "no-store", + }); + res.end(body); + }); + }) + .listen(PORT, () => { + const vendor = path.join(LOGIN, "resources/vendor/or-vaadin.js"); + if (!fs.existsSync(vendor)) { + console.warn( + "\nwarning: resources/vendor is empty - components will not render.\n" + + " run: theme/dev/build-design-system.sh ../openremote\n" + ); + } + console.log(`\n theme preview: http://localhost:${PORT}/dev/preview.html`); + console.log(` watching: ${path.relative(process.cwd(), LOGIN)}\n`); + }); diff --git a/theme/src/main/resources/theme/openremote/login/error.ftl b/theme/src/main/resources/theme/openremote/login/error.ftl index 97927dd..b0aa4a0 100644 --- a/theme/src/main/resources/theme/openremote/login/error.ftl +++ b/theme/src/main/resources/theme/openremote/login/error.ftl @@ -1,13 +1,16 @@ <#import "template.ftl" as layout> -<@layout.registrationLayout displayMessage=false; section> +<#import "field.ftl" as field> +<#-- The message is rendered once, by the layout, as an .or-alert carrying message.type - so + an error reads as one and a warning does not. Rendering it here as well printed the same + sentence twice, and rendering it here *instead* threw the type away. --> +<@layout.registrationLayout; section> <#if section = "header"> ${msg("errorTitle")} <#elseif section = "form"> -
-

${message.summary?no_esc}

- <#if client?? && client.baseUrl?has_content> -

${kcSanitize(msg("backToApplication"))?no_esc}

- -
+ <#if client?? && client.baseUrl?has_content> +

+ <@field.link href=client.baseUrl label=msg("backToApplication") id="backToApplication"/> +

+ diff --git a/theme/src/main/resources/theme/openremote/login/field.ftl b/theme/src/main/resources/theme/openremote/login/field.ftl new file mode 100644 index 0000000..039db64 --- /dev/null +++ b/theme/src/main/resources/theme/openremote/login/field.ftl @@ -0,0 +1,109 @@ +<#-- + Field macros wrapping the OpenRemote design system (@openremote/or-vaadin-components). + + Two deliberate patterns run through this file: + + 1. The native and