Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .github/workflows/keycloak.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ jobs:
java-version: '21'
cache: 'gradle'

# The login theme is a Keycloakify project (theme/ui) that Gradle drives through yarn.
# Maven is preinstalled on the runners and is required by the Keycloakify CLI.
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: '22'

- name: Build with Gradle
run: ./gradlew installDist

Expand Down
187 changes: 187 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Notes for agents

Traps that cost real time and are not visible from reading the code. `README.md` covers how
to build and run.

## Before changing the theme: connect the Figma MCP

**The design is the source of truth and it lives in Figma. Check `/mcp` is connected before
touching anything visual. If it is not, say so and stop — do not work from screenshots.**

The designs live in the OR-manager Figma file, in the "Log in pages" section — frames
`OR-login`, `OR-password`, `OR-register`, `OR-2FA` and `OR-code`, plus a third row showing
the same pages under a custom brand. Ask for the link.

Useful calls: `get_metadata` for structure and exact box geometry, `get_design_context` for
real style values and the Vaadin component each node maps to, `get_variable_defs` for the
tokens, `download_assets` with `defaultFormat: "svg"` for logos and icons.

## Design values, from the Figma tokens

They are Lumo names and `@openremote/theme` already resolves them — do not hardcode.

| | |
|---|---|
| body / links / buttons | `lumo-font-size-m` 16px |
| field labels | `lumo-font-size-s` 14px, **weight 500** |
| headings | `lumo-font-size-xl` 22px, weight 600, line-height 1.253 |
| body weight | 400, line-height 1.611 |
| **links and back-arrows** | **weight 500** — they are `<vaadin-button theme="tertiary">`, not anchors, with a `lumo:angle-left` icon |
| card | `Spacing-L` 24px padding, **flex column, 24px gap**, radius `lumo-border-radius-l` 12px |
| heading → lead | `Spacing-S` 8px |
| label → field | 6px; fields 36px tall, radius 8px |
| primary → tertiary button | 16px (the "Actions" frame) |
| 2FA steps block | 8px between every step; QR 160px with an 8px gap to its link |

**The logo is not the one in `ui/component/or-app/images`.** `theme/ui/public/logo.svg` comes
from Figma (`Login-logo`). In production the logo comes from `manager_config.json`; this is only the stock fallback.

## Environment

- Dark mode: `--blink-settings=preferredColorScheme=0`. **Not** `--force-dark-mode`, which
applies Blink's auto-darkening and gives a false pass on a theme with no dark styles.

## Vaadin / `@openremote/or-vaadin-components`

- **Form state goes on the component, never on the slotted `<input>`.** Slotting the native
input is right — `SlotController.initSingle()` reuses it rather than creating its own, and it
stays in the light DOM — but `InputControlMixin` then *manages* that element. It replaces the
`id` and **silently drops `name`, `value`, `required` and `autocomplete`**, re-delegating its
own from the host. Only `type`, `autofocus`, `inputmode` and `dir` survive on the input.

```html
<!-- wrong: renders fine, posts nothing -->
<or-vaadin-text-field><input slot="input" name="username" required></or-vaadin-text-field>
<!-- right -->
<or-vaadin-text-field name="username" required><input slot="input"></or-vaadin-text-field>
```

This shipped: every field on every page came out unnamed, so the login form posted a bare
`login=` and nothing else. It is invisible in the source, in the rendered page and in a
screenshot — **the only way to see it is `new FormData(form)` in a real browser.** The
`<label>` needs no `for`; Vaadin points it at the id it generated and adds `aria-labelledby`.
- Setting `required` on the component makes Lumo render a bullet after the label. The design has
no required markers, so `login.css` hides `::part(required-indicator)` — semantics kept,
indicator gone.
- **Two components go further and destroy what you slot in:**
- **Buttons.** Vaadin's button is `role="button"` with no `type` and no form participation,
and its Lumo styling is `:host`-scoped inside `@media lumo_components_button` so it cannot
be applied to a native `<button>`. Emit both, show one via `:defined`, and forward with
`form.requestSubmit(nativeButton)`.
- **Radio groups.** `or-vaadin-radio-group` rewrites the `name` of every radio it owns to
one generated group name, and `vaadin-radio-button` resets `value` to `"on"`. A slotted
`<input name="selectedCredentialId" value="<id>">` is silently replaced by
`name="or-vaadin-radio-group-8" value="on"`, so the form posts nothing Keycloak
understands and 2FA device selection quietly does nothing. Put `value` on the
`<vaadin-radio-button>`, and mirror the group's value into a hidden input on
`value-changed` (see `src/pages/otp.ts`).
- Assume nothing here: check with `new FormData(form)` in a real browser.
- Label color is `--vaadin-input-field-label-color`. An unqualified `#kc-content label` rule
silently overrides it on every field; scope such rules `label:not([slot])`, because Vaadin's
own labels are slotted.
- **Inter:** alias `inter-ui/inter.css` (18 faces, 2.1 MB) to `inter-ui/inter-variable-latin.css`
(~207 KB) and map `--lumo-font-family` onto `InterVariable` — `default.css` asks for `Inter`,
which will not match.
- The design system bundle appends to `document.body` during module evaluation, so it must be
loaded deferred. rspack does that by default.

## Keycloakify

- **It generates a template for all ~39 login pages and routes every one into `src/main.ts`.**
`theme/build.gradle` deletes the ones we do not implement so Keycloak serves them from its
own theme via the generated `parent=keycloak`. The keep-list is derived from `src/pages`, so
adding a page file is genuinely the only step.
- **`keycloakify build` deletes its own `build_keycloak/resources` once it has jarred it.** The
jar is the only durable output, which is why Gradle unpacks it.
- **Maven is required even when its output is discarded** — the CLI checks for `mvn --version`
before doing anything, and refuses to run with every jar target disabled.
- **`withCustomTranslations` applies to every language, not just the one you list it under.**
getI18n takes the block for the current language *or the `en` block* — so English house-style
wording put there leaks into all 30 locales and you get a Dutch page with an English card.
Only keys Keycloak has no translation for anywhere belong there; English preferences are
applied conditionally in `src/i18n.ts`.
- **Messages the server resolved outrank custom translations.** The full order is
`kcContext["x-keycloakify"].messages`, then custom translations, then the bundled set. The
server contributes any `${key}` it found in a kcContext value — user-profile labels among
them — so overriding e.g. `email` client-side changes the login page and not the register
page. Those keys also need `theme/src/main/messages/messages_en.properties`, which the build
appends to the generated bundle.
- Keycloakify writes Keycloak's whole message bundle into `login/messages/`, which is also what
makes the inherited pages translated. Append to it; do not replace it.
- `KcContextExtensionPerPage` must be `{}`, not `Record<string, never>` — the latter collapses
`ExtendKcContext` to `never` and silently turns every field access into a type error.
- `getKcContextMock` is not a plain function: use `createGetKcContextMock({...})` and take
`getKcContextMock` off the result.
- `keycloakify update-kc-gen` writes `public/keycloakify-dev-resources`, and `keycloakify build`
hard-fails if that reaches the build output; `rspack.config.mjs` excludes it from the copy.
- The generated page injects `<base href=".../dist/">`, so relative asset URLs work but relative
*link* and *form* targets would silently retarget. Use the absolute URLs from `kcContext`.

## Keycloak

- **Keycloak sends message *keys* for anything a realm can configure** —
`totp.supportedApplications`, user-profile labels, admin-authored messages. Resolve them with
`advancedMsgStr`, never a lookup table.
- Registration is driven by `profile.attributesByName` from Keycloak 24 onwards.
- **`locale` is a user-profile attribute, not a question.** With internationalization on,
Keycloak adds it to the registration profile so the language the user is reading in follows
onto the account. Its `displayName` is the bare string `locale` and it has no annotations, so
rendering the profile naively gives a text field labeled "locale" that posts back empty.
Keycloak's own `user-profile-commons.ftl` special-cases it into a hidden input; so do we.
- OTP errors come back under field **`totp`**, not `otp`.
- `otpLogin.userOtpCredentials[].userLabel` **may be blank** — rendering it raw gives a radio
with no accessible label. Keycloak's own `login-otp.ftl` has the same hole.
- `login-config-totp` arrives with a `warning` message saying the user needs to set up an
authenticator, which the page's own heading and steps already say. It is suppressed.
- **The QR code's quiet zone is not a fixed ratio.** Keycloak scales the code onto a fixed
246x246 canvas, so the white border is whatever is left after fitting however many modules
the otpauth URL needs — measured at 20px for one realm and 37px for the same realm renamed.
Nothing can be hardcoded against it; `src/qr.ts` crops to the ink and CSS re-applies a quiet
zone we control.
- Set only the realm's **login** theme. `--spi-theme-default=openremote` makes Keycloak look for
an *admin* theme of that name too, and the admin console then fails to load.

## CSS

- **Space things with flexbox `gap`, not margins.** A container owning one `gap` is easier to
make responsive, keeps spacing out of the components so they stay reusable, and removes a
whole class of margin-collapsing surprises. The Figma frames are built this way too — the
card is a flex column with a single 24px gap — so matching it means fewer values to keep in
sync. Reach for a margin only where an element genuinely owns its offset from a
*non*-sibling.
- **Margin collapsing** (for what margins remain): a `margin-top` escapes through
padding-less wrappers. Use `padding-top`.
- `#kc-content` and `#kc-content-wrapper` are `display: contents` so the card's gap reaches
the real content rather than a single pass-through div.
- **Do not put load-bearing layout behind `:has()`** — it was silently the difference between
a 16px and a 33px gap. We render the markup; a modifier class cannot fail that way.
(Progressive enhancement like `:has(> or-vaadin-button:defined)` is fine.)
- A CSS-mask icon with `contain` is inset by the box's slack. Give the box the icon's own
aspect ratio and `margin-inline-end` becomes the actual visual gap.
- `overflow-y: auto` clips `position: fixed` children. Move them to a sibling.
- Prefer `em` and derive related values, so a change of font size or image does not need a
second edit somewhere else.

## Gradle

- `project.hasProperty()` inside `onlyIf` violates the configuration cache. Capture it at
configuration time.
- The jar is assembled from two resource roots. Anything left under
`src/main/resources/theme/openremote/login` collides with the generated theme and fails
`processResources` outright.

## Verifying

The dev server (`cd theme/ui && yarn start`) renders the real pages from Keycloakify's mocks, so
unlike the previous hand-maintained preview harness it cannot drift from what ships — trust it
for layout.

**Do not trust the mocks to be representative, though.** Where the harness "fixed up" mock data
to look like the design, it hid two production bugs at once: a pre-cropped QR concealed that
real codes have a variable quiet zone, and looking right on screen concealed that the radio
group posted nothing. Prefer changing the code so the real data renders correctly over changing
the data so the code looks correct.

It also cannot tell you anything about packaging — whether a page falls through to Keycloak's
theme, whether the message merge worked, whether assets resolve under `url.resourcesPath`.
Confirm those against a real Keycloak with the jar mounted (see `README.md`).
114 changes: 109 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,120 @@ When assigning to the environment variable, it must be enclosed in double-quotes
This can be done e.g. by piping to `jq -c | sed 's/"/\\"/g'`, which would result in `"{\"realmRoles\":[\"restricted_user\"],\"clientRoles\":[{\"client\":\"openremote\",\"roles\":[\"read:assets\",\"write:attributes\"]}]}"`
The listener is not enabled by default. In Keycloak, in the `Realm settings` - `Events` - `Event listeners` admin screen, `self-register-user-configure` should be added to the list.

## Working on the OpenRemote theme
The OpenRemote theme template files are located in `theme/src/main/resources/theme/openremote`; to work on the OpenRemote theme use (ensure you are in the repo root dir first and change port as required):
## The OpenRemote theme

The jar built by `:theme` provides one Keycloak theme named `openremote`, with two types:

* **login** — built from [`theme/ui`](theme/ui), a [Keycloakify](https://keycloakify.dev)
project. The pages are TypeScript and [Lit](https://lit.dev), rendering
[`@openremote/or-vaadin-components`](https://www.npmjs.com/package/@openremote/or-vaadin-components)
styled by [`@openremote/theme`](https://www.npmjs.com/package/@openremote/theme). rspack
bundles it and Keycloakify wraps the bundle in the FreeMarker templates Keycloak serves.
* **email** — plain FreeMarker in `theme/src/main/resources/theme/openremote/email`, unaffected
by the above.

Only the pages under `theme/ui/src/pages` are ours. Keycloakify generates a template for all
~39 login pages, so the packaging step drops the ones we do not implement and Keycloak serves
those from its own theme instead (via the generated `parent=keycloak`) — see
`theme/ui/scripts/write-implemented-pages.mjs`.

### Building

```shell
./gradlew installDist # -> build/image/openremote-theme-provider.jar
docker build -t openremote/keycloak:dev .
```

Gradle drives the whole thing, so this needs **Node 20+** on the PATH as well as a JDK. It also
needs **Apache Maven**: Keycloakify shells out to it and refuses to start without it, even
though the jar it produces that way is discarded in favor of Gradle's.

Upgrading the design system is a version bump in `theme/ui/package.json` — there is no vendoring
step and nothing to keep in sync.

### Working on the login pages

The fast loop needs neither Keycloak nor a container:

```shell
cd theme/ui
yarn install
yarn start # http://localhost:5173
```

A rail down the left lists every login page — those with an implementation first, then those
inherited from Keycloak. Page data comes from Keycloakify's mocks, so every page has realistic
values, and rspack live-reloads on save. Page, color scheme and brand overrides all live in the
URL, so the state survives a reload:

```
http://localhost:5173/?page=login-config-totp.ftl
http://localhost:5173/?page=login.ftl&theme=dark
```

Adding a page means dropping a file in `theme/ui/src/pages` that exports `pageId` and `render`.
Nothing else: the page registry, the dev rail and the packaging step all derive from that.

### Testing the real pages

Build the jar and mount it into a stock Keycloak — no need to build this repo's image:

```shell
docker run --rm -p 8081:8080 -e KC_DB="dev-mem" -e KC_HOSTNAME="localhost" -e KEYCLOAK_START_OPTS="--spi-theme-static-max-age=-1 --spi-theme-cache-themes=false --spi-theme-cache-templates=false" --mount type=bind,src=$PWD/theme/src/main/resources/theme/openremote,dst=/deployment/keycloak/themes/dev openremote/keycloak:develop
./gradlew :theme:jar
docker run --rm -p 8081:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-v "$PWD/theme/build/libs/openremote-theme-provider.jar:/opt/keycloak/providers/or-theme.jar:ro" \
keycloak/keycloak:26.7.0 start-dev --spi-theme-cache-themes=false --spi-theme-static-max-age=-1
```

Then access http://localhost:8081/auth/ then create a new realm and change the template used for the realm to the `dev` template then try and login to that realm via http://localhost:8081/auth/admin/REALM_NAME/console and any changes made to the template files can be reloaded in realtime by just refreshing the window.
Then set the realm's **login theme** to `openremote` (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.

There is no bind-mount hot reload for the login pages any more, because they are a compiled
bundle rather than templates — use the dev server above to iterate and this to verify.

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`.

To get Keycloak's own themes for reference (replace `${VERSION}` with the Keycloak version used):

To get the standard themes for reference use the following (replace `${VERSION}` with actual keycloak version used):
```shell
docker cp ID:/opt/keycloak/lib/lib/main/org.keycloak.keycloak-themes-${VERSION}.jar ./
```

### Branding

Logo, application title and brand color 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.

The manager is fetched same-origin by default, which 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 `OR_MANAGER_URL` on the Keycloak container only when Keycloak is served from an origin with
no manager behind it — including a split-origin development setup:

```shell
docker run ... -e OR_MANAGER_URL=http://127.0.0.1:8080 openremote/keycloak:develop
```

It is read at render time from the theme's `theme.properties`, so it takes effect on restart with
no rebuild. 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).

### Translations

Pages address Keycloak's own message keys, and Keycloakify ships Keycloak's bundle for ~30
languages, so enabling internationalization on a realm translates the theme. OpenRemote's
departures from that wording live in `theme/ui/src/i18n.ts`; a small number of them must also be
in `theme/src/main/messages/messages_en.properties`, which explains why in its header.
Loading
Loading