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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
59 changes: 59 additions & 0 deletions theme/ui/src/pages/idp-link-confirm.ts
Original file line number Diff line number Diff line change
@@ -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<KcContext, { pageId: typeof pageId }>;

/*
* 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`
<form id="kc-idp-link-confirm-form" action=${url.loginAction} method="post">
<div class="or-actions">
<!--
Keycloak renders these the other way round and gives neither precedence. Linking is
made the primary action because it is the one the flow exists to offer: the user
has just proved they own the provider account, and reviewing the profile is the
detour. The design's Actions frame pairs exactly one primary with one tertiary.

The argument is unused by the English message ("Add to existing account") but not by
every translation, and it is what Keycloak passes.
-->
${submitButton(
msgStr("confirmLinkIdpContinue", idpDisplayName ?? idpAlias),
"submitAction",
"linkAccount"
)}
${hideReviewButton
? null
: submitButton(
msgStr("confirmLinkIdpReviewProfile"),
"submitAction",
"updateProfile",
"tertiary"
)}
</div>
</form>
`
});
}
45 changes: 45 additions & 0 deletions theme/ui/src/pages/idp-link-email.ts
Original file line number Diff line number Diff line change
@@ -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<KcContext, { pageId: typeof pageId }>;

/*
* 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`
<div class="or-prose">
<p>
${msgStr("emailLinkIdp1", provider, brokerContext.username, realm.displayName)}
</p>
<p>
${msgStr("emailLinkIdp2")}
<a class="or-link" href=${url.loginAction}>${msgStr("doClickHere")}</a>
${msgStr("emailLinkIdp3")}
</p>
<p>
${msgStr("emailLinkIdp4")}
<a class="or-link" href=${url.loginAction}>${msgStr("doClickHere")}</a>
${msgStr("emailLinkIdp5")}
</p>
</div>
`
});
}
38 changes: 38 additions & 0 deletions theme/ui/src/pages/idp-review-user-profile.ts
Original file line number Diff line number Diff line change
@@ -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<KcContext, { pageId: typeof pageId }>;

/*
* 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`
<form id="kc-idp-review-profile-form" action=${url.loginAction} method="post">
${profileFields(kcContext, i18n)}
<div class="or-actions">${submitButton(msgStr("doSubmit"))}</div>
</form>
`
});
}
84 changes: 84 additions & 0 deletions theme/ui/src/pages/info.ts
Original file line number Diff line number Diff line change
@@ -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<KcContext, { pageId: typeof pageId }>;

/*
* 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`<p class="or-card__lead">${actions}</p>` : null}
${next
? html`<p class="or-card__aside">
<a class="or-link" href=${next.href}>${next.label}</a>
</p>`
: null}
`
});
}
42 changes: 42 additions & 0 deletions theme/ui/src/pages/page-expired.ts
Original file line number Diff line number Diff line change
@@ -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<KcContext, { pageId: typeof pageId }>;

/*
* 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`
<div class="or-prose">
<p>
${msgStr("pageExpiredMsg1")}
<a class="or-link" href=${url.loginRestartFlowUrl}>${msgStr("doClickHere")}</a>.
</p>
<p>
${msgStr("pageExpiredMsg2")}
<a class="or-link" href=${url.loginAction}>${msgStr("doClickHere")}</a>.
</p>
</div>
`
});
}
39 changes: 39 additions & 0 deletions theme/ui/src/pages/update-profile.ts
Original file line number Diff line number Diff line change
@@ -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<KcContext, { pageId: typeof pageId }>;

/*
* 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`
<form id="kc-update-profile-form" action=${url.loginAction} method="post">
${profileFields(kcContext, i18n)}
<div class="or-actions">
${submitButton(msgStr("doSubmit"))}
${isAppInitiatedAction ? cancelButton(msgStr("doCancel")) : null}
</div>
</form>
`
});
}
Loading