From 2f80501637b846767bda9549dcab65bd71b0d828 Mon Sep 17 00:00:00 2001 From: Ekhorn Date: Tue, 22 Sep 2026 17:24:10 +0200 Subject: [PATCH] Add additional keycloak pages - info.ftl - login-page-expired.ftl - login-update-profile.ftl - idp-review-user-profile.ftl - login-idp-link-confirm.ftl - login-idp-link-email.ftl --- AGENTS.md | 2 + theme/ui/src/pages/idp-link-confirm.ts | 59 +++++++++++++ theme/ui/src/pages/idp-link-email.ts | 45 ++++++++++ theme/ui/src/pages/idp-review-user-profile.ts | 38 +++++++++ theme/ui/src/pages/info.ts | 84 +++++++++++++++++++ theme/ui/src/pages/page-expired.ts | 42 ++++++++++ theme/ui/src/pages/update-profile.ts | 39 +++++++++ 7 files changed, 309 insertions(+) create mode 100644 theme/ui/src/pages/idp-link-confirm.ts create mode 100644 theme/ui/src/pages/idp-link-email.ts create mode 100644 theme/ui/src/pages/idp-review-user-profile.ts create mode 100644 theme/ui/src/pages/info.ts create mode 100644 theme/ui/src/pages/page-expired.ts create mode 100644 theme/ui/src/pages/update-profile.ts diff --git a/AGENTS.md b/AGENTS.md index ce07a80..804bd8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,8 +44,10 @@ Work from the link you were given: node ids change when the file is reorganized, ## Keycloak +- The profile-driven pages (register, update-profile, identity-provider review) need Keycloak 26. `locale` arrives as a profile attribute; render it as a hidden input, as Keycloak does. - OTP errors are reported under the field `totp`, not `otp`. - `userOtpCredentials[].userLabel` may be blank; give the control a fallback name. +- The identity-brokering pages carry `idpDisplayName`; show that rather than `idpAlias`. - Keycloak's QR code has a variable quiet zone, so do not hardcode it (see `.or-qr` in `login.css`). - `Unexpected error when authenticating with identity provider` is raised by the server, not the theme; see the identity providers section of `README.md`. diff --git a/theme/ui/src/pages/idp-link-confirm.ts b/theme/ui/src/pages/idp-link-confirm.ts new file mode 100644 index 0000000..d7c11f3 --- /dev/null +++ b/theme/ui/src/pages/idp-link-confirm.ts @@ -0,0 +1,59 @@ +import { html, type TemplateResult } from "lit"; +import "@openremote/or-vaadin-components/or-vaadin-button"; +import type { I18n } from "../i18n"; +import type { KcContext } from "../login/KcContext"; +import { layout, submitButton } from "../layout"; + +export const pageId = "login-idp-link-confirm.ftl"; + +type PageContext = Extract; + +/* + * First broker login, step one: someone signed in through an identity provider and the email + * that came back already belongs to a local account. + * + * The question itself ("User with email x already exists. How do you want to continue?") + * arrives as kcContext.message, so the layout's alert is the whole of the prose here and this + * page is only the two answers. + * + * Both are POSTs to the same action distinguished by `submitAction`, which is why they are + * submit buttons in one form rather than links. + */ +export function render(kcContext: PageContext, i18n: I18n): TemplateResult { + const { url, idpAlias, idpDisplayName, hideReviewButton } = kcContext; + const { msgStr } = i18n; + + return layout({ + kcContext, + i18n, + heading: msgStr("confirmLinkIdpTitle"), + content: html` + + ` + }); +} diff --git a/theme/ui/src/pages/idp-link-email.ts b/theme/ui/src/pages/idp-link-email.ts new file mode 100644 index 0000000..fa5c56a --- /dev/null +++ b/theme/ui/src/pages/idp-link-email.ts @@ -0,0 +1,45 @@ +import { html, type TemplateResult } from "lit"; +import type { I18n } from "../i18n"; +import type { KcContext } from "../login/KcContext"; +import { layout } from "../layout"; + +export const pageId = "login-idp-link-email.ftl"; + +type PageContext = Extract; + +/* + * First broker login, step two: the realm verifies the duplicate email before linking, so it + * has sent a mail and this page is what the browser waits on. + * + * Both links go to the same URL, which is Keycloak's design rather than a mistake here: a GET + * of the login action re-sends the mail, and it is also how a user who verified in another + * browser gets this tab moving again. + */ +export function render(kcContext: PageContext, i18n: I18n): TemplateResult { + const { url, realm, idpAlias, idpDisplayName, brokerContext } = kcContext; + const { msgStr } = i18n; + const provider = idpDisplayName ?? idpAlias; + + return layout({ + kcContext, + i18n, + heading: msgStr("emailLinkIdpTitle", provider), + content: html` +
+

+ ${msgStr("emailLinkIdp1", provider, brokerContext.username, realm.displayName)} +

+

+ ${msgStr("emailLinkIdp2")} + ${msgStr("doClickHere")} + ${msgStr("emailLinkIdp3")} +

+

+ ${msgStr("emailLinkIdp4")} + ${msgStr("doClickHere")} + ${msgStr("emailLinkIdp5")} +

+
+ ` + }); +} diff --git a/theme/ui/src/pages/idp-review-user-profile.ts b/theme/ui/src/pages/idp-review-user-profile.ts new file mode 100644 index 0000000..e36f79f --- /dev/null +++ b/theme/ui/src/pages/idp-review-user-profile.ts @@ -0,0 +1,38 @@ +import { html, type TemplateResult } from "lit"; +import type { I18n } from "../i18n"; +import type { KcContext } from "../login/KcContext"; +import { layout, submitButton } from "../layout"; +import { profileFields } from "../profile"; + +export const pageId = "idp-review-user-profile.ftl"; + +type PageContext = Extract; + +/* + * First broker login, step zero: confirm the profile the provider handed over before an + * account is created from it. Shown when the realm's Review Profile step is set to "on", or to + * "missing" and the provider left something out: GitHub, for instance, gives no email at all + * unless the app asked for the user:email scope. + * + * Same form as registration, minus everything registration adds: no password, no terms, no + * captcha, because the provider has already authenticated the user. + */ +export function render(kcContext: PageContext, i18n: I18n): TemplateResult { + const { url, messagesPerField } = kcContext; + const { msgStr } = i18n; + + return layout({ + kcContext, + i18n, + heading: msgStr("loginIdpReviewProfileTitle"), + // Keycloak's own template: every field renders its own error, so only genuinely global + // messages belong in the alert. + displayMessage: messagesPerField.exists("global"), + content: html` +
+ ${profileFields(kcContext, i18n)} +
${submitButton(msgStr("doSubmit"))}
+
+ ` + }); +} diff --git a/theme/ui/src/pages/info.ts b/theme/ui/src/pages/info.ts new file mode 100644 index 0000000..e940f0c --- /dev/null +++ b/theme/ui/src/pages/info.ts @@ -0,0 +1,84 @@ +import { html, type TemplateResult } from "lit"; +import "@openremote/or-vaadin-components/or-vaadin-button"; +import type { I18n } from "../i18n"; +import type { KcContext } from "../login/KcContext"; +import { layout, resolvedHeading } from "../layout"; + +export const pageId = "info.ftl"; + +type PageContext = Extract; + +/* + * Keycloak's general-purpose "here is what happened" page. It is not in the design, but it is + * not a rare corner either. It is what a user sees at the end of several ordinary flows: + * + * "Your account has been updated." after a required action completes + * "Perform the following action(s)" after following an action-token email link + * + * plus email verification and account-deletion confirmations. Left unimplemented it fell + * through to Keycloak's own theme, so those flows ended on an unbranded page. + */ +export function render(kcContext: PageContext, i18n: I18n): TemplateResult { + const { message, messageHeader, requiredActions, skipLink, pageRedirectUri, actionUri, client } = + kcContext; + const { msgStr, advancedMsgStr } = i18n; + + /* + * requiredActions are Keycloak's own action names (UPDATE_PASSWORD, CONFIGURE_TOTP, ...), + * which resolve through the message bundle. advancedMsgStr rather than msgStr because the + * key is built at runtime and a realm may have an action we do not know about. + */ + const actions = requiredActions + ?.map(action => advancedMsgStr(`requiredAction.${action}`)) + .join(", "); + + /* + * Where to send the user next, in Keycloak's own order of preference. skipLink means the + * flow deliberately ends here, typically because the browser tab is not where the user + * continues, so offering a link would be wrong. + */ + const next = ((): { href: string; label: string } | undefined => { + if (skipLink) { + return undefined; + } + + if (pageRedirectUri) { + return { href: pageRedirectUri, label: msgStr("backToApplication") }; + } + + if (actionUri) { + return { href: actionUri, label: msgStr("proceedWithAction") }; + } + + if (client?.baseUrl) { + return { href: client.baseUrl, label: msgStr("backToApplication") }; + } + + return undefined; + })(); + + /* + * The header is a message key (see resolvedHeading). When it cannot be resolved, message.summary + * is the right thing to show instead: the server rendered it, it is already localized, and on + * this page it says the same thing the header would have. + */ + const heading = resolvedHeading(messageHeader, advancedMsgStr) ?? message.summary; + + return layout({ + kcContext, + i18n, + heading, + // The heading carries message.summary, so the alert would say it twice. + displayMessage: false, + // Only when it would not simply repeat the heading. + intro: heading === message.summary ? undefined : message.summary, + content: html` + ${actions ? html`

${actions}

` : null} + ${next + ? html`

+ ${next.label} +

` + : null} + ` + }); +} diff --git a/theme/ui/src/pages/page-expired.ts b/theme/ui/src/pages/page-expired.ts new file mode 100644 index 0000000..e3a8c8b --- /dev/null +++ b/theme/ui/src/pages/page-expired.ts @@ -0,0 +1,42 @@ +import { html, type TemplateResult } from "lit"; +import type { I18n } from "../i18n"; +import type { KcContext } from "../login/KcContext"; +import { layout } from "../layout"; + +export const pageId = "login-page-expired.ftl"; + +type PageContext = Extract; + +/* + * Shown when the login page has been open longer than the realm's login timeout, so the + * authentication session behind it is gone. Reachable from every flow: leave any login page + * on screen long enough and this is what the next click lands on, which is why it is worth + * having rather than falling through to Keycloak's own theme. + * + * The two links are not alternatives to each other in the way they read. `loginAction` + * continues the *existing* browser session's flow, which works when the user has since logged + * in in another tab; `loginRestartFlowUrl` throws it away and starts again. Keycloak offers + * both because it cannot tell which happened, and so do we. + */ +export function render(kcContext: PageContext, i18n: I18n): TemplateResult { + const { url } = kcContext; + const { msgStr } = i18n; + + return layout({ + kcContext, + i18n, + heading: msgStr("pageExpiredTitle"), + content: html` +
+

+ ${msgStr("pageExpiredMsg1")} + ${msgStr("doClickHere")}. +

+

+ ${msgStr("pageExpiredMsg2")} + ${msgStr("doClickHere")}. +

+
+ ` + }); +} diff --git a/theme/ui/src/pages/update-profile.ts b/theme/ui/src/pages/update-profile.ts new file mode 100644 index 0000000..1f1a3db --- /dev/null +++ b/theme/ui/src/pages/update-profile.ts @@ -0,0 +1,39 @@ +import { html, type TemplateResult } from "lit"; +import type { I18n } from "../i18n"; +import type { KcContext } from "../login/KcContext"; +import { cancelButton, layout, submitButton } from "../layout"; +import { profileFields } from "../profile"; + +export const pageId = "login-update-profile.ftl"; + +type PageContext = Extract; + +/* + * The UPDATE_PROFILE required action: same profile form as idp-review-user-profile, reached + * either because an administrator set the action on the account or because an application + * deep-linked to it with kc_action. + * + * It is here rather than folded into that page because the two are separate pageIds with + * separate headings and only this one can be canceled: Keycloak offers the cancel exactly + * when the application initiated the action, since only then is there something to go back to. + */ +export function render(kcContext: PageContext, i18n: I18n): TemplateResult { + const { url, isAppInitiatedAction, messagesPerField } = kcContext; + const { msgStr } = i18n; + + return layout({ + kcContext, + i18n, + heading: msgStr("loginProfileTitle"), + displayMessage: messagesPerField.exists("global"), + content: html` +
+ ${profileFields(kcContext, i18n)} +
+ ${submitButton(msgStr("doSubmit"))} + ${isAppInitiatedAction ? cancelButton(msgStr("doCancel")) : null} +
+
+ ` + }); +}