init
Some checks failed
ci / test (push) Has been cancelled
ci / Check if version upgrade (push) Has been cancelled
ci / create_github_release (push) Has been cancelled

This commit is contained in:
Edith Boles 2025-04-10 00:15:50 -07:00
parent 9537cfb060
commit 82ab2fc8fd
4 changed files with 727 additions and 0 deletions

View File

@ -1,3 +1,4 @@
import "./main.css";
import { Suspense, lazy } from "react";
import type { ClassKey } from "keycloakify/login";
import type { KcContext } from "./KcContext";

147
src/login/main.css Normal file
View File

@ -0,0 +1,147 @@
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap');
@font-face {
font-family: "Hermit";
src: url(https://penguinowl.dev/assets/Hermit-Regular.otf) format("opentype");
}
html {
background: #010b0f;
}
body.kcBodyClass {
background: linear-gradient(150deg, rgb(1, 11, 15) 0%, rgb(33, 33, 38) 100%);
}
div.kcHeaderClass {
color: #f0f0f0 !important;
}
h1#kc-page-title {
font-family: "Lato";
font-weight: 700;
font-style: normal;
text-align: left;
color: #f0f0f0;
}
label.kcLabelClass {
color: #f0f0f0;
font-family: "Lato";
font-weight: 700;
}
header.kcFormHeaderClass {
flex-direction: row-reverse !important;
justify-content: space-between;
align-items: center;
}
input.kcButtonClass {
font-family: "Hermit";
font-size: 18px;
background-color: green !important;
border-radius: 2px;
}
#kc-current-locale-link {
color: #e0e0e0 !important;
}
button.kcFormPasswordVisibilityButtonClass {
background-color: #333333 !important;
border-color: yellow !important;
height: 36px;
border-width: 1px !important;
border-radius: 2px !important;
border-style: solid;
}
i.kcFormPasswordVisibilityIconShow {
color: #f0f0f0 !important;
}
i.kcFormPasswordVisibilityIconHide {
color: #f0f0f0 !important;
}
#kc-header {
color: inherit;
margin-bottom: 0px;
}
div.kcFormCardClass {
border-radius: 30px;
border-color: none;
border-top: none;
box-shadow: 0px 0px 40px #53504a70;
border-style: solid;
border-color: #9b9b19;
background: #05061c;
}
input.kcInputClass {
background-color: #333333;
border-color: yellow;
border-radius: 2px;
color: #f0f0f0;
}
@media (max-width: 767px) {
#kc-header-wrapper {
color: #f0f0f0;
margin-bottom: 13px;
margin-left: 18px;
}
#kc-locale {
top: 36px;
}
#kc-page-title {
margin-bottom: -10px;
}
header.kcFormHeaderClass {
margin-top: 10px;
justify-content: left;
}
.login-pf-page .card-pf {
max-width: none;
margin-left: 10px;
margin-right: 10px;
padding-top: 2px;
border-top-width: 2px;
border-top-color: rgb(155, 155, 25) !important;
border-top: solid;
box-shadow: 0 0;
}
}
#kc-form-buttons {
margin-top: 0px;
}
a {
color: #e0e000;
}
.login-pf a:hover {
color: #a0f000;
}
#kc-form-options .checkbox {
color: #f0f0f0
}
.pf-m-control::after {
border-width: 0;
border-bottom-width: 0 !important;
}
.pf-c-button.pf-m-control:hover::after {
border-bottom-width: 0;
}
div.kcInputGroup {
border-radius: 2px;
border-width: 0px;
background-color: yellow;
}

View File

@ -0,0 +1,360 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login.ftl" });
const meta = {
title: "login/login.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithInvalidCredential: Story = {
render: () => (
<KcPageStory
kcContext={{
login: {
username: "johndoe"
},
messagesPerField: {
// NOTE: The other functions of messagesPerField are derived from get() and
// existsError() so they are the only ones that need to mock.
existsError: (fieldName: string, ...otherFieldNames: string[]) => {
const fieldNames = [fieldName, ...otherFieldNames];
return fieldNames.includes("username") || fieldNames.includes("password");
},
get: (fieldName: string) => {
if (fieldName === "username" || fieldName === "password") {
return "Invalid username or password.";
}
return "";
}
}
}}
/>
)
};
export const WithoutRegistration: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { registrationAllowed: false }
}}
/>
)
};
export const WithoutRememberMe: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { rememberMe: false }
}}
/>
)
};
export const WithoutPasswordReset: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { resetPasswordAllowed: false }
}}
/>
)
};
export const WithEmailAsUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { loginWithEmailAllowed: false }
}}
/>
)
};
export const WithPresetUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
login: { username: "max.mustermann@mail.com" }
}}
/>
)
};
export const WithImmutablePresetUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
auth: {
attemptedUsername: "max.mustermann@mail.com",
showUsername: true
},
usernameHidden: true,
message: {
type: "info",
summary: "Please re-authenticate to continue"
}
}}
/>
)
};
export const WithSocialProviders: Story = {
render: () => (
<KcPageStory
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
},
{
loginUrl: "facebook",
alias: "facebook",
providerId: "facebook",
displayName: "Facebook",
iconClasses: "fa fa-facebook"
},
{
loginUrl: "instagram",
alias: "instagram",
providerId: "instagram",
displayName: "Instagram",
iconClasses: "fa fa-instagram"
},
{
loginUrl: "twitter",
alias: "twitter",
providerId: "twitter",
displayName: "Twitter",
iconClasses: "fa fa-twitter"
},
{
loginUrl: "linkedin",
alias: "linkedin",
providerId: "linkedin",
displayName: "LinkedIn",
iconClasses: "fa fa-linkedin"
},
{
loginUrl: "stackoverflow",
alias: "stackoverflow",
providerId: "stackoverflow",
displayName: "Stackoverflow",
iconClasses: "fa fa-stack-overflow"
},
{
loginUrl: "github",
alias: "github",
providerId: "github",
displayName: "Github",
iconClasses: "fa fa-github"
},
{
loginUrl: "gitlab",
alias: "gitlab",
providerId: "gitlab",
displayName: "Gitlab",
iconClasses: "fa fa-gitlab"
},
{
loginUrl: "bitbucket",
alias: "bitbucket",
providerId: "bitbucket",
displayName: "Bitbucket",
iconClasses: "fa fa-bitbucket"
},
{
loginUrl: "paypal",
alias: "paypal",
providerId: "paypal",
displayName: "PayPal",
iconClasses: "fa fa-paypal"
},
{
loginUrl: "openshift",
alias: "openshift",
providerId: "openshift",
displayName: "OpenShift",
iconClasses: "fa fa-cloud"
}
]
}
}}
/>
)
};
export const WithoutPasswordField: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { password: false }
}}
/>
)
};
export const WithErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "The time allotted for the connection has elapsed.<br/>The login process will restart from the beginning.",
type: "error"
}
}}
/>
)
};
export const WithOneSocialProvider: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
}
]
}
}}
/>
)
};
export const WithTwoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
}
]
}
}}
/>
)
};
export const WithNoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: []
}
}}
/>
)
};
export const WithMoreThanTwoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
},
{
loginUrl: "facebook",
alias: "facebook",
providerId: "facebook",
displayName: "Facebook",
iconClasses: "fa fa-facebook"
},
{
loginUrl: "twitter",
alias: "twitter",
providerId: "twitter",
displayName: "Twitter",
iconClasses: "fa fa-twitter"
}
]
}
}}
/>
)
};
export const WithSocialProvidersAndWithoutRememberMe: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
}
]
},
realm: { rememberMe: false }
}}
/>
)
};

219
src/login/pages/Login.tsx Normal file
View File

@ -0,0 +1,219 @@
import type { JSX } from "keycloakify/tools/JSX";
import { useState } from "react";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
import { clsx } from "keycloakify/tools/clsx";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import { getKcClsx, type KcClsx } from "keycloakify/login/lib/kcClsx";
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, doUseDefaultCss, Template, classes } = props;
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
const { social, realm, url, usernameHidden, login, auth, registrationDisabled, messagesPerField } = kcContext;
const { msg, msgStr } = i18n;
const [isLoginButtonDisabled, setIsLoginButtonDisabled] = useState(false);
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={!messagesPerField.existsError("username", "password")}
headerNode={msg("loginAccountTitle")}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
infoNode={
<div id="kc-registration-container">
<div id="kc-registration">
<span>
{msg("noAccount")}{" "}
<a tabIndex={8} href={url.registrationUrl}>
{msg("doRegister")}
</a>
</span>
</div>
</div>
}
socialProvidersNode={
<>
{realm.password && social?.providers !== undefined && social.providers.length !== 0 && (
<div id="kc-social-providers" className={kcClsx("kcFormSocialAccountSectionClass")}>
<hr />
<h2>{msg("identity-provider-login-label")}</h2>
<ul className={kcClsx("kcFormSocialAccountListClass", social.providers.length > 3 && "kcFormSocialAccountListGridClass")}>
{social.providers.map((...[p, , providers]) => (
<li key={p.alias}>
<a
id={`social-${p.alias}`}
className={kcClsx(
"kcFormSocialAccountListButtonClass",
providers.length > 3 && "kcFormSocialAccountGridItem"
)}
type="button"
href={p.loginUrl}
>
{p.iconClasses && <i className={clsx(kcClsx("kcCommonLogoIdP"), p.iconClasses)} aria-hidden="true"></i>}
<span
className={clsx(kcClsx("kcFormSocialAccountNameClass"), p.iconClasses && "kc-social-icon-text")}
dangerouslySetInnerHTML={{ __html: kcSanitize(p.displayName) }}
></span>
</a>
</li>
))}
</ul>
</div>
)}
</>
}
>
<div id="kc-form">
<div id="kc-form-wrapper">
{realm.password && (
<form
id="kc-form-login"
onSubmit={() => {
setIsLoginButtonDisabled(true);
return true;
}}
action={url.loginAction}
method="post"
>
{!usernameHidden && (
<div className={kcClsx("kcFormGroupClass")}>
<label htmlFor="username" className={kcClsx("kcLabelClass")}>
{!realm.loginWithEmailAllowed
? msg("username")
: !realm.registrationEmailAsUsername
? msg("usernameOrEmail")
: msg("email")}
</label>
<input
tabIndex={2}
id="username"
className={kcClsx("kcInputClass")}
name="username"
defaultValue={login.username ?? ""}
type="text"
autoFocus
autoComplete="username"
aria-invalid={messagesPerField.existsError("username", "password")}
/>
{messagesPerField.existsError("username", "password") && (
<span
id="input-error"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: kcSanitize(messagesPerField.getFirstError("username", "password"))
}}
/>
)}
</div>
)}
<div className={kcClsx("kcFormGroupClass")}>
<label htmlFor="password" className={kcClsx("kcLabelClass")}>
{msg("password")}
</label>
<PasswordWrapper kcClsx={kcClsx} i18n={i18n} passwordInputId="password">
<input
tabIndex={3}
id="password"
className={kcClsx("kcInputClass")}
name="password"
type="password"
autoComplete="current-password"
aria-invalid={messagesPerField.existsError("username", "password")}
/>
</PasswordWrapper>
{usernameHidden && messagesPerField.existsError("username", "password") && (
<span
id="input-error"
className={kcClsx("kcInputErrorMessageClass")}
aria-live="polite"
dangerouslySetInnerHTML={{
__html: kcSanitize(messagesPerField.getFirstError("username", "password"))
}}
/>
)}
</div>
<div className={kcClsx("kcFormGroupClass", "kcFormSettingClass")}>
<div id="kc-form-options">
{realm.rememberMe && !usernameHidden && (
<div className="checkbox">
<label>
<input
tabIndex={5}
id="rememberMe"
name="rememberMe"
type="checkbox"
defaultChecked={!!login.rememberMe}
/>{" "}
{msg("rememberMe")}
</label>
</div>
)}
</div>
<div className={kcClsx("kcFormOptionsWrapperClass")}>
{realm.resetPasswordAllowed && (
<span>
<a tabIndex={6} href={url.loginResetCredentialsUrl}>
{msg("doForgotPassword")}
</a>
</span>
)}
</div>
</div>
<div id="kc-form-buttons" className={kcClsx("kcFormGroupClass")}>
<input type="hidden" id="id-hidden-input" name="credentialId" value={auth.selectedCredential} />
<input
tabIndex={7}
disabled={isLoginButtonDisabled}
className={kcClsx("kcButtonClass", "kcButtonPrimaryClass", "kcButtonBlockClass", "kcButtonLargeClass")}
name="login"
id="kc-login"
type="submit"
value={msgStr("doLogIn")}
/>
</div>
</form>
)}
</div>
</div>
</Template>
);
}
function PasswordWrapper(props: { kcClsx: KcClsx; i18n: I18n; passwordInputId: string; children: JSX.Element }) {
const { kcClsx, i18n, passwordInputId, children } = props;
const { msgStr } = i18n;
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
return (
<div className={kcClsx("kcInputGroup")}>
{children}
<button
type="button"
className={kcClsx("kcFormPasswordVisibilityButtonClass")}
aria-label={msgStr(isPasswordRevealed ? "hidePassword" : "showPassword")}
aria-controls={passwordInputId}
onClick={toggleIsPasswordRevealed}
>
<i className={kcClsx(isPasswordRevealed ? "kcFormPasswordVisibilityIconHide" : "kcFormPasswordVisibilityIconShow")} aria-hidden />
</button>
</div>
);
}