Add custom login page

This commit is contained in:
garronej 2023-02-26 16:35:55 +01:00
parent dd41957b56
commit 7aabee9dd5
5 changed files with 231 additions and 28 deletions

View file

@ -4,26 +4,23 @@
A starter/demo project for [Keycloakify](https://keycloakify.dev) A starter/demo project for [Keycloakify](https://keycloakify.dev)
# ⚠️ Please read the two following notices ⚠️ # ⚠️ Please read the two following notice ⚠️
> This starter is for **Component-level customization**, if you only want to customize **the page at the CSS level** > If you are only looking to create a theme and don't care about integrating it into an React app there
> heads over to [keycloakify-starter](https://github.com/garronej/keycloakify-starter).
> If you are only looking to create a theme and don't care about integrating it into a React app there
> are a lot of things that you can remove from this starter. [Please read this](#standalone-keycloak-theme). > are a lot of things that you can remove from this starter. [Please read this](#standalone-keycloak-theme).
# Quick start # Quick start
```bash ```bash
yarn yarn
yarn keycloak # Build the theme one time (some assets will be copied to yarn build-keycloak-theme # Build the theme one time (some assets will be copied to
# public/keycloak_static, they are needed to dev your page outside of Keycloak) # public/keycloak_static, they are needed to dev your page outside of Keycloak)
yarn start # See the Hello World app yarn start # See the Hello World app
# Uncomment line 15 of src/keycloakTheme/kcContext, reload https://localhost:3000 # Uncomment line 15 of src/keycloakTheme/kcContext, reload https://localhost:3000
# You can now develop your Login pages. # You can now develop your Login pages.
# Think your theme is ready? Run # Think your theme is ready? Run
yarn keycloak yarn build-keycloak-theme
# Read the instruction printed on the console to see how to test # Read the instruction printed on the console to see how to test
# your theme on a real Keycloak instance. # your theme on a real Keycloak instance.
``` ```
@ -66,11 +63,11 @@ More info on the `--external-assets` build option [here](https://docs.keycloakif
# Docker # Docker
```bash ```bash
docker build -f Dockerfile -t garronej/keycloakify-advanced-starter:test . docker build -f Dockerfile -t codegouvfr/keycloakify-starter:test .
#OR (to reproduce how the image is built in the ci workflow): #OR (to reproduce how the image is built in the ci workflow):
yarn && yarn build && tar -cvf build.tar ./build && docker build -f Dockerfile.ci -t garronej/keycloakify-advanced-starter:test . && rm build.tar yarn && yarn build && tar -cvf build.tar ./build && docker build -f Dockerfile.ci -t codegouvfr/keycloakify-starter:test . && rm build.tar
docker run -it -dp 8083:80 garronej/keycloakify-advanced-starter:test docker run -it -dp 8083:80 garronej/keycloakify-starter:test
``` ```
## DockerHub credentials ## DockerHub credentials

View file

@ -1,16 +1,16 @@
{ {
"name": "keycloakify-advanced-starter", "name": "keycloakify-starter",
"homepage": "https://demo-app-advanced.keycloakify.dev", "homepage": "https://starter.keycloakify.dev",
"version": "1.0.8", "version": "1.0.8",
"description": "A starter/demo project for keycloakify", "description": "A starter/demo project for keycloakify",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/garronej/keycloakify-advanced-starter.git" "url": "git://github.com/codegouvfr/keycloakify-starter.git"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
"build": "react-scripts build", "build": "react-scripts build",
"keycloak": "yarn build && keycloakify", "build-keycloak-theme": "yarn build && keycloakify",
"download-builtin-keycloak-theme": "download-builtin-keycloak-theme 15.0.2" "download-builtin-keycloak-theme": "download-builtin-keycloak-theme 15.0.2"
}, },
"keycloakify": { "keycloakify": {

View file

@ -3,15 +3,16 @@ import { lazy, Suspense } from "react";
import type { KcContext } from "./kcContext"; import type { KcContext } from "./kcContext";
import { useI18n } from "./i18n"; import { useI18n } from "./i18n";
import Fallback, { defaultKcProps, type KcProps, type PageProps } from "keycloakify"; import Fallback, { defaultKcProps, type KcProps, type PageProps } from "keycloakify";
// Here we have overloaded the default template, however you could use the default one with:
//import Template from "keycloakify/lib/Template";
import Template from "./Template"; import Template from "./Template";
import DefaultTemplate from "keycloakify/lib/Template";
const Login = lazy(() => import("keycloakify/lib/pages/Login")); const Login = lazy(()=> import("./pages/Login"));
// If you can, favor register-user-profile.ftl over register.ftl, see: https://docs.keycloakify.dev/realtime-input-validation
const Register = lazy(() => import("./pages/Register")); const Register = lazy(() => import("./pages/Register"));
const Terms = lazy(() => import("./pages/Terms")); const Terms = lazy(() => import("./pages/Terms"));
const MyExtraPage1 = lazy(() => import("./pages/MyExtraPage1")); const MyExtraPage1 = lazy(() => import("./pages/MyExtraPage1"));
const MyExtraPage2 = lazy(() => import("./pages/MyExtraPage2")); const MyExtraPage2 = lazy(() => import("./pages/MyExtraPage2"));
const Info = lazy(()=> import("keycloakify/lib/pages/Info"));
// This is like editing the theme.properties // This is like editing the theme.properties
// https://github.com/keycloak/keycloak/blob/11.0.3/themes/src/main/resources/theme/keycloak/login/theme.properties // https://github.com/keycloak/keycloak/blob/11.0.3/themes/src/main/resources/theme/keycloak/login/theme.properties
@ -35,7 +36,10 @@ export default function App(props: { kcContext: KcContext; }) {
const pageProps: Omit<PageProps<any, typeof i18n>, "kcContext"> = { const pageProps: Omit<PageProps<any, typeof i18n>, "kcContext"> = {
i18n, i18n,
// Here we have overloaded the default template, however you could use the default one with:
//Template: DefaultTemplate,
Template, Template,
// Wether or not we should download the CSS and JS resources that comes with the default Keycloak theme.
doFetchDefaultThemeResources: true, doFetchDefaultThemeResources: true,
...kcProps, ...kcProps,
}; };
@ -49,6 +53,8 @@ export default function App(props: { kcContext: KcContext; }) {
case "terms.ftl": return <Terms {...{ kcContext, ...pageProps }} />; case "terms.ftl": return <Terms {...{ kcContext, ...pageProps }} />;
case "my-extra-page-1.ftl": return <MyExtraPage1 {...{ kcContext, ...pageProps }} />; case "my-extra-page-1.ftl": return <MyExtraPage1 {...{ kcContext, ...pageProps }} />;
case "my-extra-page-2.ftl": return <MyExtraPage2 {...{ kcContext, ...pageProps }} />; case "my-extra-page-2.ftl": return <MyExtraPage2 {...{ kcContext, ...pageProps }} />;
// We choose to use the default Template for the Info page and to download the theme resources.
case "info.ftl": return <Info {...{ kcContext, ...pageProps}} Template={DefaultTemplate} doFetchDefaultThemeResources={true} />;
default: return <Fallback {...{ kcContext, ...pageProps }} />; default: return <Fallback {...{ kcContext, ...pageProps }} />;
} }
})()} })()}

View file

@ -17,7 +17,7 @@ export const { kcContext } = getKcContext<
| { pageId: "register.ftl"; authorizedMailDomains: string[]; } | { pageId: "register.ftl"; authorizedMailDomains: string[]; }
>({ >({
// Uncomment to test the login page for development. // Uncomment to test the login page for development.
// mockPageId: "login.ftl", //mockPageId: "login.ftl",
mockData: [ mockData: [
{ {
pageId: "login.ftl", pageId: "login.ftl",
@ -34,16 +34,6 @@ export const { kcContext } = getKcContext<
pageId: "my-extra-page-2.ftl", pageId: "my-extra-page-2.ftl",
someCustomValue: "foo bar baz" someCustomValue: "foo bar baz"
}, },
{
pageId: "register.ftl",
authorizedMailDomains: [
"example.com",
"another-example.com",
"*.yet-another-example.com",
"*.example.com",
"hello-world.com"
]
},
{ {
//NOTE: You will either use register.ftl (legacy) or register-user-profile.ftl, not both //NOTE: You will either use register.ftl (legacy) or register-user-profile.ftl, not both
pageId: "register-user-profile.ftl", pageId: "register-user-profile.ftl",
@ -81,6 +71,24 @@ export const { kcContext } = getKcContext<
} }
] ]
} }
},
{
pageId: "register.ftl",
authorizedMailDomains: [
"example.com",
"another-example.com",
"*.yet-another-example.com",
"*.example.com",
"hello-world.com"
],
// Simulate we got an error with the email field
messagesPerField: {
printIfExists: <T>(fieldName: string, className: T) => { console.log({ fieldName}); return fieldName === "email" ? className : undefined; },
existsError: (fieldName: string)=> fieldName === "email",
get: (fieldName: string) => `Fake error for ${fieldName}`,
exists: (fieldName: string) => fieldName === "email"
},
} }
] ]
}); });

View file

@ -0,0 +1,192 @@
import { useState, type FormEventHandler } from "react";
import { clsx } from "keycloakify/lib/tools/clsx";
import { useConstCallback } from "keycloakify/lib/tools/useConstCallback";
import type { PageProps } from "keycloakify/lib/KcProps";
import type { KcContext } from "../kcContext";
import type { I18n } from "../i18n";
export default function Login(props: PageProps<Extract<KcContext, { pageId: "login.ftl"; }>, I18n>) {
const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props;
const { social, realm, url, usernameEditDisabled, login, auth, registrationDisabled } = kcContext;
const { msg, msgStr } = i18n;
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);
const onSubmit = useConstCallback<FormEventHandler<HTMLFormElement>>(e => {
e.preventDefault();
setIsLoginButtonDisabled(true);
const formElement = e.target as HTMLFormElement;
//NOTE: Even if we login with email Keycloak expect username and password in
//the POST request.
formElement.querySelector("input[name='email']")?.setAttribute("name", "username");
formElement.submit();
});
return (
<Template
{...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }}
displayInfo={social.displayInfo}
displayWide={realm.password && social.providers !== undefined}
headerNode={msg("doLogIn")}
formNode={
<div id="kc-form" className={clsx(realm.password && social.providers !== undefined && kcProps.kcContentWrapperClass)}>
<div
id="kc-form-wrapper"
className={clsx(
realm.password && social.providers && [kcProps.kcFormSocialAccountContentClass, kcProps.kcFormSocialAccountClass]
)}
>
{realm.password && (
<form id="kc-form-login" onSubmit={onSubmit} action={url.loginAction} method="post">
<div className={clsx(kcProps.kcFormGroupClass)}>
{(() => {
const label = !realm.loginWithEmailAllowed
? "username"
: realm.registrationEmailAsUsername
? "email"
: "usernameOrEmail";
const autoCompleteHelper: typeof label = label === "usernameOrEmail" ? "username" : label;
return (
<>
<label htmlFor={autoCompleteHelper} className={clsx(kcProps.kcLabelClass)}>
{msg(label)}
</label>
<input
tabIndex={1}
id={autoCompleteHelper}
className={clsx(kcProps.kcInputClass)}
//NOTE: This is used by Google Chrome auto fill so we use it to tell
//the browser how to pre fill the form but before submit we put it back
//to username because it is what keycloak expects.
name={autoCompleteHelper}
defaultValue={login.username ?? ""}
type="text"
{...(usernameEditDisabled
? { "disabled": true }
: {
"autoFocus": true,
"autoComplete": "off"
})}
/>
</>
);
})()}
</div>
<div className={clsx(kcProps.kcFormGroupClass)}>
<label htmlFor="password" className={clsx(kcProps.kcLabelClass)}>
{msg("password")}
</label>
<input
tabIndex={2}
id="password"
className={clsx(kcProps.kcInputClass)}
name="password"
type="password"
autoComplete="off"
/>
</div>
<div className={clsx(kcProps.kcFormGroupClass, kcProps.kcFormSettingClass)}>
<div id="kc-form-options">
{realm.rememberMe && !usernameEditDisabled && (
<div className="checkbox">
<label>
<input
tabIndex={3}
id="rememberMe"
name="rememberMe"
type="checkbox"
{...(login.rememberMe
? {
"checked": true
}
: {})}
/>
{msg("rememberMe")}
</label>
</div>
)}
</div>
<div className={clsx(kcProps.kcFormOptionsWrapperClass)}>
{realm.resetPasswordAllowed && (
<span>
<a tabIndex={5} href={url.loginResetCredentialsUrl}>
{msg("doForgotPassword")}
</a>
</span>
)}
</div>
</div>
<div id="kc-form-buttons" className={clsx(kcProps.kcFormGroupClass)}>
<input
type="hidden"
id="id-hidden-input"
name="credentialId"
{...(auth?.selectedCredential !== undefined
? {
"value": auth.selectedCredential
}
: {})}
/>
<input
tabIndex={4}
className={clsx(
kcProps.kcButtonClass,
kcProps.kcButtonPrimaryClass,
kcProps.kcButtonBlockClass,
kcProps.kcButtonLargeClass
)}
name="login"
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
disabled={isLoginButtonDisabled}
/>
</div>
</form>
)}
</div>
{realm.password && social.providers !== undefined && (
<div id="kc-social-providers" className={clsx(kcProps.kcFormSocialAccountContentClass, kcProps.kcFormSocialAccountClass)}>
<ul
className={clsx(
kcProps.kcFormSocialAccountListClass,
social.providers.length > 4 && kcProps.kcFormSocialAccountDoubleListClass
)}
>
{social.providers.map(p => (
<li key={p.providerId} className={clsx(kcProps.kcFormSocialAccountListLinkClass)}>
<a href={p.loginUrl} id={`zocial-${p.alias}`} className={clsx("zocial", p.providerId)}>
<span>{p.displayName}</span>
</a>
</li>
))}
</ul>
</div>
)}
</div>
}
infoNode={
realm.password &&
realm.registrationAllowed &&
!registrationDisabled && (
<div id="kc-registration">
<span>
{msg("noAccount")}
<a tabIndex={6} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
)
}
/>
);
}