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)
|
||||
|
||||
# ⚠️ 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
|
||||
|
|
|
@ -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": {
|
||||
|
|
|
@ -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 }} />;
|
||||
}
|
||||
})()}
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
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