Add custom login page
This commit is contained in:
parent
dd41957b56
commit
7aabee9dd5
17
README.md
17
README.md
|
@ -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
|
||||||
|
|
|
@ -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": {
|
||||||
|
|
|
@ -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 }} />;
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
|
|
|
@ -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"
|
||||||
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
192
src/keycloakTheme/pages/Login.tsx
Normal file
192
src/keycloakTheme/pages/Login.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in a new issue