From 7a3866b783fd209167db1d3ad07f70ee9f9fa601 Mon Sep 17 00:00:00 2001 From: Uday Raj Sahai Date: Fri, 7 Aug 2026 14:54:01 +0530 Subject: [PATCH 01/22] Fix login alert and social divider alignment --- src/login/assets/civic-theme.css | 55 ++++++++++++++------ src/login/pages/LogoutConfirm.stories.tsx | 62 +++++++++++++++++++++++ 2 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 src/login/pages/LogoutConfirm.stories.tsx diff --git a/src/login/assets/civic-theme.css b/src/login/assets/civic-theme.css index dfe93c6..d37d960 100644 --- a/src/login/assets/civic-theme.css +++ b/src/login/assets/civic-theme.css @@ -196,21 +196,29 @@ input.error { } .checkbox { - display: flex; - align-items: center; - gap: 8px; font-size: 14px; color: var(--civic-text-dark); cursor: pointer; } +.checkbox label { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + cursor: pointer; + line-height: 1.2; +} + .checkbox input[type="checkbox"] { width: 18px; height: 18px; - margin-right: 12px; + margin: 0; + padding: 0; cursor: pointer; accent-color: var(--civic-primary-blue); flex-shrink: 0; + vertical-align: middle; } #kc-form-options a { @@ -398,28 +406,24 @@ select.form-control:focus, font-weight: 500; color: var(--civic-text-light); margin: 24px 0 20px 0; + padding-top: 24px; } #kc-social-providers h2::before, -#kc-social-providers h2::after, -#kc-social-providers h4::before, -#kc-social-providers h4::after { +#kc-social-providers h4::before { content: ''; position: absolute; - top: 50%; - width: 45%; + top: 0; + left: 0; + right: 0; + width: 100%; height: 1px; background: var(--civic-border); } -#kc-social-providers h2::before, -#kc-social-providers h4::before { - left: 0; -} - #kc-social-providers h2::after, #kc-social-providers h4::after { - right: 0; + content: none; } /* Provider list */ @@ -593,10 +597,29 @@ button[type="button"].pf-c-button:hover, /* Alert Messages */ .pf-c-alert, .alert { - padding: 12px 16px; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 12px 0; margin-bottom: 20px; border-radius: 4px; font-size: 14px; + line-height: 1.4; +} + +.pf-c-alert__icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + line-height: 1.4; + margin: 0; +} + +.pf-c-alert__title { + flex: 1; + margin: 0; + line-height: 1.4; } .pf-c-alert.pf-m-danger, diff --git a/src/login/pages/LogoutConfirm.stories.tsx b/src/login/pages/LogoutConfirm.stories.tsx new file mode 100644 index 0000000..8ea237f --- /dev/null +++ b/src/login/pages/LogoutConfirm.stories.tsx @@ -0,0 +1,62 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { createKcPageStory } from "../KcPageStory"; + +const { KcPageStory } = createKcPageStory({ pageId: "logout-confirm.ftl" }); + +const meta = { + title: "login/logout-confirm.ftl", + component: KcPageStory +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +/** Default: "Do you want to log out?" + Logout button only */ +export const Default: Story = { + render: () => +}; + +/** With "Back to application" link visible */ +export const WithBackToApplication: Story = { + render: () => ( + + ) +}; + +/** Session-over style warning message + logout confirm */ +export const WithSessionMessage: Story = { + render: () => ( + + ) +}; From 7fe1921d75e68c57589365a5662be3cbb1691b82 Mon Sep 17 00:00:00 2001 From: Uday Raj Sahai Date: Tue, 18 Aug 2026 14:28:54 +0530 Subject: [PATCH 02/22] Add custom Keycloak login theme, icons, and civic styling --- package-lock.json | 32 +- package.json | 3 +- src/login/KcPage.tsx | 25 +- src/login/UserProfileFormFields.tsx | 647 ++++++++++++++++++++ src/login/assets/civic-theme.css | 293 +++++++-- src/login/components/CustomTemplate.tsx | 23 +- src/login/components/FieldErrorIcon.tsx | 5 + src/login/components/PasswordWrapper.tsx | 37 ++ src/login/components/SocialProviderIcon.tsx | 234 +++++++ src/login/pages/Login.tsx | 210 +++++++ yarn.lock | 12 + 11 files changed, 1441 insertions(+), 80 deletions(-) create mode 100644 src/login/UserProfileFormFields.tsx create mode 100644 src/login/components/FieldErrorIcon.tsx create mode 100644 src/login/components/PasswordWrapper.tsx create mode 100644 src/login/components/SocialProviderIcon.tsx create mode 100644 src/login/pages/Login.tsx diff --git a/package-lock.json b/package-lock.json index fce37d9..600b0cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "keycloakify": "^11.8.42", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "@tabler/icons-react": "^3.46.0" }, "devDependencies": { "@eslint/js": "^9.15.0", @@ -3881,6 +3882,22 @@ "url": "https://opencollective.com/storybook" } }, + "node_modules/@tabler/icons": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz", + "integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==" + }, + "node_modules/@tabler/icons-react": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz", + "integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==", + "dependencies": { + "@tabler/icons": "3.46.0" + }, + "peerDependencies": { + "react": ">= 16" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -14391,6 +14408,19 @@ "file-system-cache": "2.3.0" } }, + "@tabler/icons": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/@tabler/icons/-/icons-3.46.0.tgz", + "integrity": "sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A==" + }, + "@tabler/icons-react": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.46.0.tgz", + "integrity": "sha512-CCm7xJWhDT2PH4ZIFkP6AgYKtVhq0gpYkjUN+GVh1AzmIQaa77OW0bQPBPQiTE0PsXMR9oSxFqA3qBglzPyrVQ==", + "requires": { + "@tabler/icons": "3.46.0" + } + }, "@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", diff --git a/package.json b/package.json index c7cd73f..a9db965 100755 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "keycloakify": "^11.8.42", "react": "^18.2.0", "react-dom": "^18.2.0", - "@keycloakify/email-native": "~260007.0.0" + "@keycloakify/email-native": "~260007.0.0", + "@tabler/icons-react": "^3.46.0" }, "devDependencies": { "@eslint/js": "^9.15.0", diff --git a/src/login/KcPage.tsx b/src/login/KcPage.tsx index 717a774..94c728b 100644 --- a/src/login/KcPage.tsx +++ b/src/login/KcPage.tsx @@ -6,9 +6,8 @@ import "./assets/civic-theme.css"; import CustomTemplate from "./components/CustomTemplate"; import { useI18n } from "./i18n"; -const UserProfileFormFields = lazy( - () => import("keycloakify/login/UserProfileFormFields") -); +const Login = lazy(() => import("./pages/Login")); +const UserProfileFormFields = lazy(() => import("./UserProfileFormFields")); const doMakeUserConfirmPassword = true; @@ -21,6 +20,24 @@ export default function KcPage(props: { kcContext: KcContext }) { {(() => { switch (kcContext.pageId) { + case "login.ftl": + return ( + ( + + {templateProps.children} + + )} + doUseDefaultCss={true} + /> + ); default: return ( ( - ) { + const { kcContext, i18n, kcClsx, onIsFormSubmittableValueChange, doMakeUserConfirmPassword, BeforeField, AfterField } = props; + + const { advancedMsg } = i18n; + + const { + formState: { formFieldStates, isFormSubmittable }, + dispatchFormAction + } = useUserProfileForm({ + kcContext, + i18n, + doMakeUserConfirmPassword + }); + + useEffect(() => { + onIsFormSubmittableValueChange(isFormSubmittable); + }, [isFormSubmittable]); + + const groupNameRef = { current: "" }; + + return ( + <> + {formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => { + return ( + + + {BeforeField !== undefined && ( + + )} +
+
+ + {attribute.required && <> *} +
+
+ {attribute.annotations.inputHelperTextBefore !== undefined && ( +
+ {advancedMsg(attribute.annotations.inputHelperTextBefore)} +
+ )} + + + {attribute.annotations.inputHelperTextAfter !== undefined && ( +
+ {advancedMsg(attribute.annotations.inputHelperTextAfter)} +
+ )} + {AfterField !== undefined && ( + + )} + {/* NOTE: Downloading of html5DataAnnotations scripts is done in the useUserProfileForm hook */} +
+
+
+ ); + })} + + ); +} + +function GroupLabel(props: { + attribute: Attribute; + groupNameRef: { + current: string; + }; + i18n: I18n; + kcClsx: KcClsx; +}) { + const { attribute, groupNameRef, i18n, kcClsx } = props; + + const { advancedMsg } = i18n; + + if (attribute.group?.name !== groupNameRef.current) { + groupNameRef.current = attribute.group?.name ?? ""; + + if (groupNameRef.current !== "") { + assert(attribute.group !== undefined); + + return ( +
[`data-${key}`, value]))} + > + {(() => { + const groupDisplayHeader = attribute.group.displayHeader ?? ""; + const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name; + + return ( +
+ +
+ ); + })()} + {(() => { + const groupDisplayDescription = attribute.group.displayDescription ?? ""; + + if (groupDisplayDescription !== "") { + const groupDescriptionText = advancedMsg(groupDisplayDescription); + + return ( +
+ +
+ ); + } + + return null; + })()} +
+ ); + } + } + + return null; +} + +function FieldErrors(props: { attribute: Attribute; displayableErrors: FormFieldError[]; fieldIndex: number | undefined; kcClsx: KcClsx }) { + const { attribute, fieldIndex, kcClsx } = props; + + const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex); + + if (displayableErrors.length === 0) { + return null; + } + + return ( + + + + {displayableErrors + .filter(error => error.fieldIndex === fieldIndex) + .map(({ errorMessage }, i, arr) => ( + + {errorMessage} + {arr.length - 1 !== i &&
} +
+ ))} +
+
+ ); +} + +type InputFieldByTypeProps = { + attribute: Attribute; + valueOrValues: string | string[]; + displayableErrors: FormFieldError[]; + dispatchFormAction: React.Dispatch; + i18n: I18n; + kcClsx: KcClsx; +}; + +function InputFieldByType(props: InputFieldByTypeProps) { + const { attribute, valueOrValues } = props; + + switch (attribute.annotations.inputType) { + // NOTE: Unfortunately, keycloak won't let you define input type="hidden" in the Admin Console. + // sometimes in the future it might. + case "hidden": + return ; + case "textarea": + return ; + case "select": + case "multiselect": + return ; + case "select-radiobuttons": + case "multiselect-checkboxes": + return ; + default: { + if (valueOrValues instanceof Array) { + return ( + <> + {valueOrValues.map((...[, i]) => ( + + ))} + + ); + } + + const inputNode = ; + + if (attribute.name === "password" || attribute.name === "password-confirm") { + return ( + + {inputNode} + + ); + } + + return inputNode; + } + } +} + +function InputTag(props: InputFieldByTypeProps & { fieldIndex: number | undefined }) { + const { attribute, fieldIndex, kcClsx, dispatchFormAction, valueOrValues, i18n, displayableErrors } = props; + + const { advancedMsgStr } = i18n; + + return ( + <> + { + const { inputType } = attribute.annotations; + + if (inputType?.startsWith("html5-")) { + return inputType.slice(6); + } + + return inputType ?? "text"; + })()} + id={attribute.name} + name={attribute.name} + value={(() => { + if (fieldIndex !== undefined) { + assert(valueOrValues instanceof Array); + return valueOrValues[fieldIndex]; + } + + assert(typeof valueOrValues === "string"); + + return valueOrValues; + })()} + className={kcClsx("kcInputClass")} + aria-invalid={displayableErrors.find(error => error.fieldIndex === fieldIndex) !== undefined} + disabled={attribute.readOnly} + autoComplete={attribute.autocomplete} + placeholder={ + attribute.annotations.inputTypePlaceholder === undefined ? undefined : advancedMsgStr(attribute.annotations.inputTypePlaceholder) + } + pattern={attribute.annotations.inputTypePattern} + size={attribute.annotations.inputTypeSize === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeSize}`)} + maxLength={ + attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`) + } + minLength={ + attribute.annotations.inputTypeMinlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMinlength}`) + } + max={attribute.annotations.inputTypeMax} + min={attribute.annotations.inputTypeMin} + step={attribute.annotations.inputTypeStep} + {...Object.fromEntries(Object.entries(attribute.html5DataAnnotations ?? {}).map(([key, value]) => [`data-${key}`, value]))} + onChange={event => + dispatchFormAction({ + action: "update", + name: attribute.name, + valueOrValues: (() => { + if (fieldIndex !== undefined) { + assert(valueOrValues instanceof Array); + + return valueOrValues.map((value, i) => { + if (i === fieldIndex) { + return event.target.value; + } + + return value; + }); + } + + return event.target.value; + })() + }) + } + onBlur={() => + dispatchFormAction({ + action: "focus lost", + name: attribute.name, + fieldIndex: fieldIndex + }) + } + /> + {(() => { + if (fieldIndex === undefined) { + return null; + } + + assert(valueOrValues instanceof Array); + + const values = valueOrValues; + + return ( + <> + + + + ); + })()} + + ); +} + +function AddRemoveButtonsMultiValuedAttribute(props: { + attribute: Attribute; + values: string[]; + fieldIndex: number; + dispatchFormAction: React.Dispatch>; + i18n: I18n; +}) { + const { attribute, values, fieldIndex, dispatchFormAction, i18n } = props; + + const { msg } = i18n; + + const { hasAdd, hasRemove } = getButtonToDisplayForMultivaluedAttributeField({ attribute, values, fieldIndex }); + + const idPostfix = `-${attribute.name}-${fieldIndex + 1}`; + + return ( + <> + {hasRemove && ( + <> + + {hasAdd ? <> |  : null} + + )} + {hasAdd && ( + + )} + + ); +} + +function InputTagSelects(props: InputFieldByTypeProps) { + const { attribute, dispatchFormAction, kcClsx, i18n, valueOrValues } = props; + + const { classDiv, classInput, classLabel, inputType } = (() => { + const { inputType } = attribute.annotations; + + assert(inputType === "select-radiobuttons" || inputType === "multiselect-checkboxes"); + + switch (inputType) { + case "select-radiobuttons": + return { + inputType: "radio", + classDiv: kcClsx("kcInputClassRadio"), + classInput: kcClsx("kcInputClassRadioInput"), + classLabel: kcClsx("kcInputClassRadioLabel") + }; + case "multiselect-checkboxes": + return { + inputType: "checkbox", + classDiv: kcClsx("kcInputClassCheckbox"), + classInput: kcClsx("kcInputClassCheckboxInput"), + classLabel: kcClsx("kcInputClassCheckboxLabel") + }; + } + })(); + + const options = (() => { + walk: { + const { inputOptionsFromValidation } = attribute.annotations; + + if (inputOptionsFromValidation === undefined) { + break walk; + } + + const validator = (attribute.validators as Record)[inputOptionsFromValidation]; + + if (validator === undefined) { + break walk; + } + + if (validator.options === undefined) { + break walk; + } + + return validator.options; + } + + return attribute.validators.options?.options ?? []; + })(); + + return ( + <> + {options.map(option => ( +
+ + dispatchFormAction({ + action: "update", + name: attribute.name, + valueOrValues: (() => { + const isChecked = event.target.checked; + + if (valueOrValues instanceof Array) { + const newValues = [...valueOrValues]; + + if (isChecked) { + newValues.push(option); + } else { + newValues.splice(newValues.indexOf(option), 1); + } + + return newValues; + } + + return event.target.checked ? option : ""; + })() + }) + } + onBlur={() => + dispatchFormAction({ + action: "focus lost", + name: attribute.name, + fieldIndex: undefined + }) + } + /> + +
+ ))} + + ); +} + +function TextareaTag(props: InputFieldByTypeProps) { + const { attribute, dispatchFormAction, kcClsx, displayableErrors, valueOrValues } = props; + + assert(typeof valueOrValues === "string"); + + const value = valueOrValues; + + return ( +