diff options
Diffstat (limited to 'src')
28 files changed, 671 insertions, 77 deletions
diff --git a/src/keycloak-theme/pages/IdpReviewUserProfile.stories.tsx b/src/keycloak-theme/pages/IdpReviewUserProfile.stories.tsx index e3d6915..660216c 100644 --- a/src/keycloak-theme/pages/IdpReviewUserProfile.stories.tsx +++ b/src/keycloak-theme/pages/IdpReviewUserProfile.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('idp-review-user-profile.ftl'); export default { kind: 'Page', @@ -11,7 +13,5 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('idp-review-user-profile.ftl'); - export const Default = bind({}) diff --git a/src/keycloak-theme/pages/IdpReviewUserProfile.tsx b/src/keycloak-theme/pages/IdpReviewUserProfile.tsx index ec73693..2e4f789 100644 --- a/src/keycloak-theme/pages/IdpReviewUserProfile.tsx +++ b/src/keycloak-theme/pages/IdpReviewUserProfile.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { clsx } from "keycloakify/lib/tools/clsx"; -import { UserProfileFormFields } from "./shared/UserProfileCommons"; +import { UserProfileFormFields } from "keycloakify/lib/pages/shared/UserProfileCommons"; import type { PageProps } from "keycloakify"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/keycloak-theme/pages/Info.stories.tsx b/src/keycloak-theme/pages/Info.stories.tsx index e6bacc5..4fd483f 100644 --- a/src/keycloak-theme/pages/Info.stories.tsx +++ b/src/keycloak-theme/pages/Info.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('info.ftl'); export default { kind: 'Page', @@ -11,7 +13,6 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('info.ftl'); export const Default = bind({ messageHeader: 'Yo, get this:', diff --git a/src/keycloak-theme/pages/Login.stories.tsx b/src/keycloak-theme/pages/Login.stories.tsx index a2e9f8d..9cafd82 100644 --- a/src/keycloak-theme/pages/Login.stories.tsx +++ b/src/keycloak-theme/pages/Login.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {socialProviders, template} from '../../../.storybook/util' +import { socialProviders, template } from '../../../.storybook/util' + +const bind = template('login.ftl'); export default { kind: 'Page', @@ -11,17 +13,15 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('login.ftl'); - export const Default = bind({}) -export const WithoutPasswordField = bind({realm: {password: false}}) -export const WithoutRegistration = bind({realm: {registrationAllowed: false}}) -export const WithoutRememberMe = bind({realm: {rememberMe: false}}) -export const WithoutPasswordReset = bind({realm: {resetPasswordAllowed: false}}) -export const WithEmailAsUsername = bind({realm: {loginWithEmailAllowed: false}}) -export const WithPresetUsername = bind({login: {username: '[email protected]'}}) +export const WithoutPasswordField = bind({ realm: { password: false } }) +export const WithoutRegistration = bind({ realm: { registrationAllowed: false } }) +export const WithoutRememberMe = bind({ realm: { rememberMe: false } }) +export const WithoutPasswordReset = bind({ realm: { resetPasswordAllowed: false } }) +export const WithEmailAsUsername = bind({ realm: { loginWithEmailAllowed: false } }) +export const WithPresetUsername = bind({ login: { username: '[email protected]' } }) export const WithImmutablePresetUsername = bind({ - login: {username: '[email protected]'}, + login: { username: '[email protected]' }, usernameEditDisabled: true }) -export const WithSocialProviders = bind({social: {displayInfo: true, providers: socialProviders}}) +export const WithSocialProviders = bind({ social: { displayInfo: true, providers: socialProviders } }) diff --git a/src/keycloak-theme/pages/LoginConfigTotp.stories.tsx b/src/keycloak-theme/pages/LoginConfigTotp.stories.tsx index b0a673f..d798dd2 100644 --- a/src/keycloak-theme/pages/LoginConfigTotp.stories.tsx +++ b/src/keycloak-theme/pages/LoginConfigTotp.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('login-config-totp.ftl'); export default { kind: 'Page', @@ -11,17 +13,15 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('login-config-totp.ftl'); - export const Default = bind({}) -export const WithManualSetUp = bind({mode: 'manual'}) +export const WithManualSetUp = bind({ mode: 'manual' }) export const WithError = bind({ messagesPerField: { get: (fieldName: string) => fieldName === 'totp' ? 'Invalid TOTP' : undefined, exists: (fieldName: string) => fieldName === 'totp', existsError: (fieldName: string) => fieldName === 'totp', - printIfExists: <T, >(fieldName: string, x: T) => fieldName === 'totp' ? x : undefined + printIfExists: <T,>(fieldName: string, x: T) => fieldName === 'totp' ? x : undefined } }) diff --git a/src/keycloak-theme/pages/LoginIdpLinkConfirm.stories.tsx b/src/keycloak-theme/pages/LoginIdpLinkConfirm.stories.tsx index 55ff499..75617bb 100644 --- a/src/keycloak-theme/pages/LoginIdpLinkConfirm.stories.tsx +++ b/src/keycloak-theme/pages/LoginIdpLinkConfirm.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('login-idp-link-confirm.ftl'); export default { kind: 'Page', @@ -11,7 +13,5 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('login-idp-link-confirm.ftl'); - export const Default = bind({}) diff --git a/src/keycloak-theme/pages/LoginIdpLinkEmail.stories.tsx b/src/keycloak-theme/pages/LoginIdpLinkEmail.stories.tsx index 98f280b..1d03385 100644 --- a/src/keycloak-theme/pages/LoginIdpLinkEmail.stories.tsx +++ b/src/keycloak-theme/pages/LoginIdpLinkEmail.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('login-idp-link-email.ftl'); export default { kind: 'Page', @@ -11,7 +13,5 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('login-idp-link-email.ftl'); - export const Default = bind({}) diff --git a/src/keycloak-theme/pages/LoginOtp.stories.tsx b/src/keycloak-theme/pages/LoginOtp.stories.tsx index 21e90bb..f674379 100644 --- a/src/keycloak-theme/pages/LoginOtp.stories.tsx +++ b/src/keycloak-theme/pages/LoginOtp.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('login-otp.ftl'); export default { kind: 'Page', @@ -11,6 +13,4 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('login-otp.ftl'); - export const Default = bind({}) diff --git a/src/keycloak-theme/pages/LoginPageExpired.stories.tsx b/src/keycloak-theme/pages/LoginPageExpired.stories.tsx index c991a2f..f5de885 100644 --- a/src/keycloak-theme/pages/LoginPageExpired.stories.tsx +++ b/src/keycloak-theme/pages/LoginPageExpired.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {socialProviders, template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('login-page-expired.ftl'); export default { kind: 'Page', @@ -11,6 +13,4 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('login-page-expired.ftl'); - export const Default = bind({}) diff --git a/src/keycloak-theme/pages/LoginPassword.stories.tsx b/src/keycloak-theme/pages/LoginPassword.stories.tsx index 6a53efa..5106dfc 100644 --- a/src/keycloak-theme/pages/LoginPassword.stories.tsx +++ b/src/keycloak-theme/pages/LoginPassword.stories.tsx @@ -1,16 +1,16 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('login-password.ftl'); export default { kind: 'Page', - title: 'Theme/Pages/Login/Password Only', + title: 'Theme/Pages/Login/Password', component: KcApp, parameters: { layout: 'fullscreen', }, } as ComponentMeta<typeof KcApp>; -const bind = template('login-password.ftl'); - export const Default = bind({}) diff --git a/src/keycloak-theme/pages/LoginResetPassword.stories.tsx b/src/keycloak-theme/pages/LoginResetPassword.stories.tsx index 9ea3736..c417dd8 100644 --- a/src/keycloak-theme/pages/LoginResetPassword.stories.tsx +++ b/src/keycloak-theme/pages/LoginResetPassword.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('login-reset-password.ftl'); export default { kind: 'Page', @@ -11,7 +13,5 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('login-reset-password.ftl'); - export const Default = bind({}) -export const WithEmailAsUsername = bind({realm: {loginWithEmailAllowed: true, registrationEmailAsUsername: true}}) +export const WithEmailAsUsername = bind({ realm: { loginWithEmailAllowed: true, registrationEmailAsUsername: true } }) diff --git a/src/keycloak-theme/pages/LoginUpdatePassword.stories.tsx b/src/keycloak-theme/pages/LoginUpdatePassword.stories.tsx index cf5177e..91f2d7d 100644 --- a/src/keycloak-theme/pages/LoginUpdatePassword.stories.tsx +++ b/src/keycloak-theme/pages/LoginUpdatePassword.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('login-update-password.ftl'); export default { kind: 'Page', @@ -11,6 +13,4 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('login-update-password.ftl'); - export const Default = bind({}) diff --git a/src/keycloak-theme/pages/LoginUsername.stories.tsx b/src/keycloak-theme/pages/LoginUsername.stories.tsx new file mode 100644 index 0000000..21e63c0 --- /dev/null +++ b/src/keycloak-theme/pages/LoginUsername.stories.tsx @@ -0,0 +1,17 @@ +import { ComponentMeta } from '@storybook/react'; +import KcApp from '../KcApp'; +import { template } from '../../../.storybook/util' + +const bind = template('login-username.ftl'); + +export default { + kind: 'Page', + title: 'Theme/Pages/Login/Username', + component: KcApp, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta<typeof KcApp>; + +export const Default = bind({}) +export const WithEmailAsUsername = bind({ realm: { loginWithEmailAllowed: true, registrationEmailAsUsername: true } }) diff --git a/src/keycloak-theme/pages/LoginUsername.tsx b/src/keycloak-theme/pages/LoginUsername.tsx new file mode 100644 index 0000000..7ea5e0a --- /dev/null +++ b/src/keycloak-theme/pages/LoginUsername.tsx @@ -0,0 +1,158 @@ +import React, { useState } from "react"; +import { clsx } from "keycloakify/lib/tools/clsx"; +import { useConstCallback } from "keycloakify/lib/tools/useConstCallback"; +import type { FormEventHandler } from "react"; +import type { PageProps } from "keycloakify"; +import type { KcContext } from "../kcContext"; +import type { I18n } from "../i18n"; + +export default function LoginUsername(props: PageProps<Extract<KcContext, { pageId: "login-username.ftl" }>, I18n>) { + const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props; + + const { social, realm, url, usernameHidden, login, 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)}> + {!usernameHidden && + (() => { + 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" + autoFocus={true} + autoComplete="off" + /> + </> + ); + })()} + </div> + <div className={clsx(kcProps.kcFormGroupClass, kcProps.kcFormSettingClass)}> + <div id="kc-form-options"> + {realm.rememberMe && !usernameHidden && ( + <div className="checkbox"> + <label> + <input + tabIndex={3} + id="rememberMe" + name="rememberMe" + type="checkbox" + {...(login.rememberMe + ? { + "checked": true + } + : {})} + /> + {msg("rememberMe")} + </label> + </div> + )} + </div> + </div> + <div id="kc-form-buttons" className={clsx(kcProps.kcFormGroupClass)}> + <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> + ) + } + /> + ); +} diff --git a/src/keycloak-theme/pages/LoginVerifyEmail.stories.tsx b/src/keycloak-theme/pages/LoginVerifyEmail.stories.tsx new file mode 100644 index 0000000..2bc48a1 --- /dev/null +++ b/src/keycloak-theme/pages/LoginVerifyEmail.stories.tsx @@ -0,0 +1,16 @@ +import { ComponentMeta } from '@storybook/react'; +import KcApp from '../KcApp'; +import { template } from '../../../.storybook/util' + +const bind = template('login-verify-email.ftl'); + +export default { + kind: 'Page', + title: 'Theme/Pages/Login/Verify Email', + component: KcApp, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta<typeof KcApp>; + +export const Default = bind({}) diff --git a/src/keycloak-theme/pages/LoginVerifyEmail.tsx b/src/keycloak-theme/pages/LoginVerifyEmail.tsx new file mode 100644 index 0000000..513ef3a --- /dev/null +++ b/src/keycloak-theme/pages/LoginVerifyEmail.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import type { PageProps } from "keycloakify"; +import type { KcContext } from "../kcContext"; +import type { I18n } from "../i18n"; + +export default function LoginVerifyEmail(props: PageProps<Extract<KcContext, { pageId: "login-verify-email.ftl" }>, I18n>) { + const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props; + + const { msg } = i18n; + + const { url, user } = kcContext; + + return ( + <Template + {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }} + displayMessage={false} + headerNode={msg("emailVerifyTitle")} + formNode={ + <> + <p className="instruction">{msg("emailVerifyInstruction1", user?.email)}</p> + <p className="instruction"> + {msg("emailVerifyInstruction2")} + <br /> + <a href={url.loginAction}>{msg("doClickHere")}</a> + + {msg("emailVerifyInstruction3")} + </p> + </> + } + /> + ); +} diff --git a/src/keycloak-theme/pages/LogoutConfirm.stories.tsx b/src/keycloak-theme/pages/LogoutConfirm.stories.tsx new file mode 100644 index 0000000..e8fd2e5 --- /dev/null +++ b/src/keycloak-theme/pages/LogoutConfirm.stories.tsx @@ -0,0 +1,16 @@ +import { ComponentMeta } from '@storybook/react'; +import KcApp from '../KcApp'; +import { template } from '../../../.storybook/util' + +const bind = template('logout-confirm.ftl'); + +export default { + kind: 'Page', + title: 'Theme/Pages/Login/Logout Confirmation', + component: KcApp, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta<typeof KcApp>; + +export const Default = bind({}) diff --git a/src/keycloak-theme/pages/LogoutConfirm.tsx b/src/keycloak-theme/pages/LogoutConfirm.tsx new file mode 100644 index 0000000..5347f64 --- /dev/null +++ b/src/keycloak-theme/pages/LogoutConfirm.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { clsx } from "keycloakify/lib/tools/clsx"; +import type { PageProps } from "keycloakify/lib/KcProps"; +import type { KcContext } from "../kcContext"; +import type { I18n } from "../i18n"; + +export default function LogoutConfirm(props: PageProps<Extract<KcContext, { pageId: "logout-confirm.ftl" }>, I18n>) { + const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props; + + const { url, client, logoutConfirm } = kcContext; + + const { msg, msgStr } = i18n; + + return ( + <Template + {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }} + displayMessage={false} + headerNode={msg("logoutConfirmTitle")} + formNode={ + <> + <div id="kc-logout-confirm" className="content-area"> + <p className="instruction">{msg("logoutConfirmHeader")}</p> + <form className="form-actions" action={url.logoutConfirmAction} method="POST"> + <input type="hidden" name="session_code" value={logoutConfirm.code} /> + <div className={clsx(kcProps.kcFormGroupClass)}> + <div id="kc-form-options"> + <div className={clsx(kcProps.kcFormOptionsWrapperClass)}></div> + </div> + <div id="kc-form-buttons" className={clsx(kcProps.kcFormGroupClass)}> + <input + tabIndex={4} + className={clsx( + kcProps.kcButtonClass, + kcProps.kcButtonPrimaryClass, + kcProps.kcButtonBlockClass, + kcProps.kcButtonLargeClass + )} + name="confirmLogout" + id="kc-logout" + type="submit" + value={msgStr("doLogout")} + /> + </div> + </div> + </form> + <div id="kc-info-message"> + {!logoutConfirm.skipLink && client.baseUrl && ( + <p> + <a href={client.baseUrl} dangerouslySetInnerHTML={{ __html: msgStr("backToApplication") }} /> + </p> + )} + </div> + </div> + </> + } + /> + ); +} diff --git a/src/keycloak-theme/pages/MyExtraPage1.stories.tsx b/src/keycloak-theme/pages/MyExtraPage1.stories.tsx index ef3e198..d1e5055 100644 --- a/src/keycloak-theme/pages/MyExtraPage1.stories.tsx +++ b/src/keycloak-theme/pages/MyExtraPage1.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('my-extra-page-1.ftl') export default { kind: 'Page', @@ -11,6 +13,4 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('my-extra-page-1.ftl') - export const Default = bind({}) diff --git a/src/keycloak-theme/pages/MyExtraPage2.stories.tsx b/src/keycloak-theme/pages/MyExtraPage2.stories.tsx index 196f5a0..462ba7a 100644 --- a/src/keycloak-theme/pages/MyExtraPage2.stories.tsx +++ b/src/keycloak-theme/pages/MyExtraPage2.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('my-extra-page-2.ftl') export default { kind: 'Page', @@ -11,8 +13,6 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('my-extra-page-2.ftl') - export const Default = bind({}) -export const WithCustomValue = bind({someCustomValue: 'Foo Bar Baz'}) +export const WithCustomValue = bind({ someCustomValue: 'Foo Bar Baz' }) diff --git a/src/keycloak-theme/pages/Register.stories.tsx b/src/keycloak-theme/pages/Register.stories.tsx index 6947fff..ddc1872 100644 --- a/src/keycloak-theme/pages/Register.stories.tsx +++ b/src/keycloak-theme/pages/Register.stories.tsx @@ -1,6 +1,8 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' + +const bind = template('register.ftl') export default { kind: 'Page', @@ -11,8 +13,6 @@ export default { }, } as ComponentMeta<typeof KcApp>; -const bind = template('register.ftl') - export const Default = bind({}) export const WithFieldError = bind({ @@ -25,12 +25,12 @@ export const WithFieldError = bind({ existsError: (fieldName: string) => fieldName === "email", exists: (fieldName: string) => fieldName === "email", get: (fieldName: string) => fieldName === "email" ? "I don't like your email address" : undefined, - printIfExists: <T, >(fieldName: string, x: T) => fieldName === "email" ? x : undefined, + printIfExists: <T,>(fieldName: string, x: T) => fieldName === "email" ? x : undefined, } }) export const WithEmailAsUsername = bind({ - realm: {registrationEmailAsUsername: true} + realm: { registrationEmailAsUsername: true } }) export const WithoutPassword = bind({ diff --git a/src/keycloak-theme/pages/RegisterUserProfile.stories.tsx b/src/keycloak-theme/pages/RegisterUserProfile.stories.tsx index 3e34c39..adc6685 100644 --- a/src/keycloak-theme/pages/RegisterUserProfile.stories.tsx +++ b/src/keycloak-theme/pages/RegisterUserProfile.stories.tsx @@ -1,6 +1,6 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' const bind = template('register-user-profile.ftl') @@ -8,11 +8,12 @@ export default { kind: 'Page', title: 'Theme/Pages/Register/Modern', component: KcApp, - parameters: {layout: 'fullscreen'}, + parameters: { layout: 'fullscreen' }, } as ComponentMeta<typeof KcApp>; export const Default = bind({}) +/* export const WithFieldError = bind({ profile: { attributes: [ @@ -77,3 +78,5 @@ export const WithImmutablePresets = bind({ ] } }) + +*/
\ No newline at end of file diff --git a/src/keycloak-theme/pages/RegisterUserProfile.tsx b/src/keycloak-theme/pages/RegisterUserProfile.tsx index 492e82b..e3b1f77 100644 --- a/src/keycloak-theme/pages/RegisterUserProfile.tsx +++ b/src/keycloak-theme/pages/RegisterUserProfile.tsx @@ -1,7 +1,7 @@ // Copy pasted from: https://github.com/InseeFrLab/keycloakify/blob/main/src/lib/pages/RegisterUserProfile.tsx import { useState } from "react"; import { clsx } from "keycloakify/lib/tools/clsx"; -import { UserProfileFormFields } from "./shared/UserProfileCommons"; +import { UserProfileFormFields } from "keycloakify/lib/pages/shared/UserProfileCommons"; import type { PageProps } from "keycloakify/lib/KcProps"; import type { KcContext } from "../kcContext"; import type { I18n } from "../i18n"; diff --git a/src/keycloak-theme/pages/Terms.stories.tsx b/src/keycloak-theme/pages/Terms.stories.tsx index 5f5e238..28ff76c 100644 --- a/src/keycloak-theme/pages/Terms.stories.tsx +++ b/src/keycloak-theme/pages/Terms.stories.tsx @@ -1,8 +1,9 @@ -import {ComponentMeta} from '@storybook/react'; +import { ComponentMeta } from '@storybook/react'; import KcApp from '../KcApp'; -import {template} from '../../../.storybook/util' +import { template } from '../../../.storybook/util' const bind = template('terms.ftl'); + export default { kind: 'Page', title: 'Theme/Pages/Actions/Terms', diff --git a/src/keycloak-theme/pages/UpdateUserProfile.stories.tsx b/src/keycloak-theme/pages/UpdateUserProfile.stories.tsx new file mode 100644 index 0000000..8057558 --- /dev/null +++ b/src/keycloak-theme/pages/UpdateUserProfile.stories.tsx @@ -0,0 +1,16 @@ +import { ComponentMeta } from '@storybook/react'; +import KcApp from '../KcApp'; +import { template } from '../../../.storybook/util' + +const bind = template('update-user-profile.ftl'); + +export default { + kind: 'Page', + title: 'Theme/Pages/Actions/Update User Profile', + component: KcApp, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta<typeof KcApp>; + +export const Default = bind({}) diff --git a/src/keycloak-theme/pages/UpdateUserProfile.tsx b/src/keycloak-theme/pages/UpdateUserProfile.tsx new file mode 100644 index 0000000..6bfb1e2 --- /dev/null +++ b/src/keycloak-theme/pages/UpdateUserProfile.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import { clsx } from "keycloakify/lib/tools/clsx"; +import { UserProfileFormFields } from "keycloakify/lib/pages/shared/UserProfileCommons"; +import type { PageProps } from "keycloakify/lib/KcProps"; +import type { KcContext } from "../kcContext"; +import type { I18n } from "../i18n"; + +export default function UpdateUserProfile(props: PageProps<Extract<KcContext, { pageId: "update-user-profile.ftl" }>, I18n>) { + const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props; + + const { msg, msgStr } = i18n; + + const { url, isAppInitiatedAction } = kcContext; + + const [isFomSubmittable, setIsFomSubmittable] = useState(false); + + return ( + <Template + {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }} + headerNode={msg("loginProfileTitle")} + formNode={ + <form id="kc-update-profile-form" className={clsx(kcProps.kcFormClass)} action={url.loginAction} method="post"> + <UserProfileFormFields kcContext={kcContext} onIsFormSubmittableValueChange={setIsFomSubmittable} i18n={i18n} {...kcProps} /> + + <div className={clsx(kcProps.kcFormGroupClass)}> + <div id="kc-form-options" className={clsx(kcProps.kcFormOptionsClass)}> + <div className={clsx(kcProps.kcFormOptionsWrapperClass)}></div> + </div> + + <div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}> + {isAppInitiatedAction ? ( + <> + <input + className={clsx(kcProps.kcButtonClass, kcProps.kcButtonPrimaryClass, kcProps.kcButtonLargeClass)} + type="submit" + value={msgStr("doSubmit")} + /> + <button + className={clsx(kcProps.kcButtonClass, kcProps.kcButtonDefaultClass, kcProps.kcButtonLargeClass)} + type="submit" + name="cancel-aia" + value="true" + formNoValidate + > + {msg("doCancel")} + </button> + </> + ) : ( + <input + className={clsx( + kcProps.kcButtonClass, + kcProps.kcButtonPrimaryClass, + kcProps.kcButtonBlockClass, + kcProps.kcButtonLargeClass + )} + type="submit" + defaultValue={msgStr("doSubmit")} + disabled={!isFomSubmittable} + /> + )} + </div> + </div> + </form> + } + /> + ); +} diff --git a/src/keycloak-theme/pages/WebauthnAuthenticate.stories.tsx b/src/keycloak-theme/pages/WebauthnAuthenticate.stories.tsx new file mode 100644 index 0000000..62a84ee --- /dev/null +++ b/src/keycloak-theme/pages/WebauthnAuthenticate.stories.tsx @@ -0,0 +1,16 @@ +import { ComponentMeta } from '@storybook/react'; +import KcApp from '../KcApp'; +import { template } from '../../../.storybook/util' + +const bind = template('webauthn-authenticate.ftl'); + +export default { + kind: 'Page', + title: 'Theme/Pages/Login/Webauthn', + component: KcApp, + parameters: { + layout: 'fullscreen', + }, +} as ComponentMeta<typeof KcApp>; + +export const Default = bind({}) diff --git a/src/keycloak-theme/pages/WebauthnAuthenticate.tsx b/src/keycloak-theme/pages/WebauthnAuthenticate.tsx new file mode 100644 index 0000000..fd14dc8 --- /dev/null +++ b/src/keycloak-theme/pages/WebauthnAuthenticate.tsx @@ -0,0 +1,193 @@ +import React, { useRef, useState } from "react"; +import { clsx } from "keycloakify/lib/tools/clsx"; +import type { MessageKeyBase } from "keycloakify/lib/i18n"; +import { base64url } from "rfc4648"; +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 WebauthnAuthenticate(props: PageProps<Extract<KcContext, { pageId: "webauthn-authenticate.ftl" }>, I18n>) { + const { kcContext, i18n, doFetchDefaultThemeResources = true, Template, ...kcProps } = props; + + const { url } = kcContext; + + const { msg, msgStr } = i18n; + + const { authenticators, challenge, shouldDisplayAuthenticators, userVerification, rpId } = kcContext; + const createTimeout = Number(kcContext.createTimeout); + const isUserIdentified = kcContext.isUserIdentified == "true"; + + const webAuthnAuthenticate = useConstCallback(async () => { + if (!isUserIdentified) { + return; + } + const allowCredentials = authenticators.authenticators.map( + authenticator => + ({ + id: base64url.parse(authenticator.credentialId, { loose: true }), + type: "public-key" + } as PublicKeyCredentialDescriptor) + ); + // Check if WebAuthn is supported by this browser + if (!window.PublicKeyCredential) { + setError(msgStr("webauthn-unsupported-browser-text")); + submitForm(); + return; + } + + const publicKey: PublicKeyCredentialRequestOptions = { + rpId, + challenge: base64url.parse(challenge, { loose: true }) + }; + + if (createTimeout !== 0) { + publicKey.timeout = createTimeout * 1000; + } + + if (allowCredentials.length) { + publicKey.allowCredentials = allowCredentials; + } + + if (userVerification !== "not specified") { + publicKey.userVerification = userVerification; + } + + try { + const resultRaw = await navigator.credentials.get({ publicKey }); + if (!resultRaw || resultRaw.type != "public-key") return; + const result = resultRaw as PublicKeyCredential; + if (!("authenticatorData" in result.response)) return; + const response = result.response as AuthenticatorAssertionResponse; + const clientDataJSON = response.clientDataJSON; + const authenticatorData = response.authenticatorData; + const signature = response.signature; + + setClientDataJSON(base64url.stringify(new Uint8Array(clientDataJSON), { pad: false })); + setAuthenticatorData(base64url.stringify(new Uint8Array(authenticatorData), { pad: false })); + setSignature(base64url.stringify(new Uint8Array(signature), { pad: false })); + setCredentialId(result.id); + setUserHandle(base64url.stringify(new Uint8Array(response.userHandle!), { pad: false })); + submitForm(); + } catch (err) { + setError(String(err)); + submitForm(); + } + }); + + const webAuthForm = useRef<HTMLFormElement>(null); + const submitForm = useConstCallback(() => { + webAuthForm.current!.submit(); + }); + + const [clientDataJSON, setClientDataJSON] = useState(""); + const [authenticatorData, setAuthenticatorData] = useState(""); + const [signature, setSignature] = useState(""); + const [credentialId, setCredentialId] = useState(""); + const [userHandle, setUserHandle] = useState(""); + const [error, setError] = useState(""); + + return ( + <Template + {...{ kcContext, i18n, doFetchDefaultThemeResources, ...kcProps }} + headerNode={msg("webauthn-login-title")} + formNode={ + <div id="kc-form-webauthn" className={clsx(kcProps.kcFormClass)}> + <form id="webauth" action={url.loginAction} ref={webAuthForm} method="post"> + <input type="hidden" id="clientDataJSON" name="clientDataJSON" value={clientDataJSON} /> + <input type="hidden" id="authenticatorData" name="authenticatorData" value={authenticatorData} /> + <input type="hidden" id="signature" name="signature" value={signature} /> + <input type="hidden" id="credentialId" name="credentialId" value={credentialId} /> + <input type="hidden" id="userHandle" name="userHandle" value={userHandle} /> + <input type="hidden" id="error" name="error" value={error} /> + </form> + <div className={clsx(kcProps.kcFormGroupClass)}> + {authenticators && + (() => ( + <form id="authn_select" className={clsx(kcProps.kcFormClass)}> + {authenticators.authenticators.map(authenticator => ( + <input + type="hidden" + name="authn_use_chk" + value={authenticator.credentialId} + key={authenticator.credentialId} + /> + ))} + </form> + ))()} + {authenticators && + shouldDisplayAuthenticators && + (() => ( + <> + {authenticators.authenticators.length > 1 && ( + <p className={clsx(kcProps.kcSelectAuthListItemTitle)}>{msg("webauthn-available-authenticators")}</p> + )} + <div className={clsx(kcProps.kcFormClass)}> + {authenticators.authenticators.map(authenticator => ( + <div id="kc-webauthn-authenticator" className={clsx(kcProps.kcSelectAuthListItemClass)}> + <div className={clsx(kcProps.kcSelectAuthListItemIconClass)}> + <i + className={clsx( + kcProps[authenticator.transports.iconClass] ?? kcProps.kcWebAuthnDefaultIcon, + kcProps.kcSelectAuthListItemIconPropertyClass + )} + /> + </div> + <div className={clsx(kcProps.kcSelectAuthListItemBodyClass)}> + <div + id="kc-webauthn-authenticator-label" + className={clsx(kcProps.kcSelectAuthListItemHeadingClass)} + > + {authenticator.label} + </div> + + {authenticator.transports && authenticator.transports.displayNameProperties.length && ( + <div + id="kc-webauthn-authenticator-transport" + className={clsx(kcProps.kcSelectAuthListItemDescriptionClass)} + > + {authenticator.transports.displayNameProperties.map( + (transport: MessageKeyBase, index: number) => ( + <> + <span>{msg(transport)}</span> + {index < authenticator.transports.displayNameProperties.length - 1 && ( + <span>{", "}</span> + )} + </> + ) + )} + </div> + )} + + <div className={clsx(kcProps.kcSelectAuthListItemDescriptionClass)}> + <span id="kc-webauthn-authenticator-created-label">{msg("webauthn-createdAt-label")}</span> + <span id="kc-webauthn-authenticator-created">{authenticator.createdAt}</span> + </div> + </div> + <div className={clsx(kcProps.kcSelectAuthListItemFillClass)} /> + </div> + ))} + </div> + </> + ))()} + <div id="kc-form-buttons" className={clsx(kcProps.kcFormButtonsClass)}> + <input + id="authenticateWebAuthnButton" + type="button" + onClick={webAuthnAuthenticate} + autoFocus={true} + value={msgStr("webauthn-doAuthenticate")} + className={clsx( + kcProps.kcButtonClass, + kcProps.kcButtonPrimaryClass, + kcProps.kcButtonBlockClass, + kcProps.kcButtonLargeClass + )} + /> + </div> + </div> + </div> + } + /> + ); +} |