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)
# ⚠️ 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**
> 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
> If you are only looking to create a theme and don't care about integrating it into an React app there
> are a lot of things that you can remove from this starter. [Please read this](#standalone-keycloak-theme).
# Quick start
```bash
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)
yarn start # See the Hello World app
# Uncomment line 15 of src/keycloakTheme/kcContext, reload https://localhost:3000
# You can now develop your Login pages.
# Think your theme is ready? Run
yarn keycloak
yarn build-keycloak-theme
# Read the instruction printed on the console to see how to test
# your theme on a real Keycloak instance.
```
@ -66,11 +63,11 @@ More info on the `--external-assets` build option [here](https://docs.keycloakif
# Docker
```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):
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

View file

@ -1,16 +1,16 @@
{
"name": "keycloakify-advanced-starter",
"homepage": "https://demo-app-advanced.keycloakify.dev",
"name": "keycloakify-starter",
"homepage": "https://starter.keycloakify.dev",
"version": "1.0.8",
"description": "A starter/demo project for keycloakify",
"repository": {
"type": "git",
"url": "git://github.com/garronej/keycloakify-advanced-starter.git"
"url": "git://github.com/codegouvfr/keycloakify-starter.git"
},
"scripts": {
"start": "react-scripts start",
"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"
},
"keycloakify": {

View file

@ -3,15 +3,16 @@ import { lazy, Suspense } from "react";
import type { KcContext } from "./kcContext";
import { useI18n } from "./i18n";
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 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 Terms = lazy(() => import("./pages/Terms"));
const MyExtraPage1 = lazy(() => import("./pages/MyExtraPage1"));
const MyExtraPage2 = lazy(() => import("./pages/MyExtraPage2"));
const Info = lazy(()=> import("keycloakify/lib/pages/Info"));
// 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
@ -35,7 +36,10 @@ export default function App(props: { kcContext: KcContext; }) {
const pageProps: Omit<PageProps<any, typeof i18n>, "kcContext"> = {
i18n,
// Here we have overloaded the default template, however you could use the default one with:
//Template: DefaultTemplate,
Template,
// Wether or not we should download the CSS and JS resources that comes with the default Keycloak theme.
doFetchDefaultThemeResources: true,
...kcProps,
};
@ -49,6 +53,8 @@ export default function App(props: { kcContext: KcContext; }) {
case "terms.ftl": return <Terms {...{ kcContext, ...pageProps }} />;
case "my-extra-page-1.ftl": return <MyExtraPage1 {...{ 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 }} />;
}
})()}

View file

@ -34,16 +34,6 @@ export const { kcContext } = getKcContext<
pageId: "my-extra-page-2.ftl",
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
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>
)
}
/>
);
}