Improved design
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:
Tom Butcher 2025-08-04 01:33:44 +01:00
parent f7789858fa
commit e23ac4cc27
35 changed files with 928 additions and 956 deletions

View File

@ -26,11 +26,11 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
i18n, i18n,
doUseDefaultCss, doUseDefaultCss,
children children
} = props; } = props;
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n; const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
const { realm, auth, url, message, isAppInitiatedAction } = kcContext; const { realm, auth, url, message, isAppInitiatedAction, client } = kcContext;
const isMobile = useMediaQuery({ maxWidth: 600 }); const isMobile = useMediaQuery({ maxWidth: 600 });
@ -53,26 +53,26 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
useEffect(() => { useEffect(() => {
setDarkMode(windowQuery.matches ? true : false); setDarkMode(windowQuery.matches ? true : false);
document.fonts.ready.then(() => { document.fonts.ready.then(() => {
// Wait for all images // Wait for all images
const images = Array.from(document.images); const images = Array.from(document.images);
if (images.length === 0) { if (images.length === 0) {
setLoading(false); setLoading(false);
return; return;
}
let loaded = 0;
function checkDone() {
loaded++;
if (loaded === images.length) setLoading(false);
}
images.forEach(img => {
if (img.complete) {
checkDone();
} else {
img.addEventListener("load", checkDone);
img.addEventListener("error", checkDone);
} }
let loaded = 0;
function checkDone() {
loaded++;
if (loaded === images.length) setLoading(false);
}
images.forEach(img => {
if (img.complete) {
checkDone();
} else {
img.addEventListener("load", checkDone);
img.addEventListener("error", checkDone);
}
});
}); });
});
}, []); }, []);
useEffect(() => { useEffect(() => {
@ -105,138 +105,179 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
colorPrimary: "rgba(212, 0, 255, 1)", colorPrimary: "rgba(212, 0, 255, 1)",
colorLink: "#6E00FF", colorLink: "#6E00FF",
colorLinkHover: "#b175ff", colorLinkHover: "#b175ff",
borderRadius: 15 borderRadius: 20
}, },
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm
}} }}
> >
<Layout style={{ minHeight: "var(--unit-100vh)", maxHeight: "var(--unit-100vh)" }}> <Layout style={{ minHeight: "var(--unit-100vh)", maxHeight: "var(--unit-100vh)" }}>
<ParticlesBackground /> <ParticlesBackground />
{loading == true ? (<div className="loadingOverlay" style={{backgroundColor: darkMode ? '#000000' : '#ffffff'}}> {loading == true ? (
<Spin/> <div className="loadingOverlay" style={{ backgroundColor: darkMode ? "#000000" : "#ffffff" }}>
</div>) : null} <Spin />
</div>
) : null}
<Content <Content
style={{ style={{
background: "#f5f5f5", background: "#f5f5f5"
}} }}
> >
<Flex vertical align="center" justify="center" style={{width: '100vw', height: "var(--unit-100vh)"}} gap={'50px'}> <Flex vertical align="center" justify="center" style={{ width: "100vw", height: "var(--unit-100vh)" }} gap={"40px"}>
{!isMobile && <img src="https://cdn.tombutcher.work/logos/logo-horizontal.svg" alt="Logo" style={{ height: "65px", padding: "0 30px", zIndex: 1 }} /> } {!isMobile && (
<Card style={{ borderRadius: isMobile ? "0px" : "20px", width: isMobile ? "100vw" : "450px", zIndex: 1, height: isMobile ? "100vh" : "unset", padding: "30px 15px", boxShadow: "0px 5px 15px 5px rgb(0 0 0 / 10%)", <img
}} variant="borderless" styles={{body: { src="https://cdn.tombutcher.work/logos/logo-horizontal.svg"
padding: 0 alt="Logo"
}}}><div style={{ height: "100%" }}> style={{ height: "60px", padding: "0 30px", zIndex: 1, marginBottom: 5 }}
<div style={{height: "100%", margin: "0 15px"}}> />
{isMobile && <><img src="https://cdn.tombutcher.work/logos/logo-auth.png" alt="Logo" style={{ width: "70%", margin: "0" }} /> <Divider style={{ margin: "24px 0" }} /></>}
<div style={{ marginBottom: "24px" }}>
{(() => {
const node = !(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
<Title level={2} style={{ marginBottom: "16px" }}>
{headerNode}
</Title>
) : (
<>
<div style={{ display: "flex", alignItems: "center", marginBottom: "24px" }}>
<Flex gap={"small"}>
<img
src={"https://cdn.tombutcher.work/icons/auth/c-person.svg"}
width={14}
style={{ marginTop: "3px" }}
/>
<Text strong>{auth.attemptedUsername}</Text>
</Flex>
<Button
type="link"
href={url.loginRestartFlowUrl}
title={msgStr("restartLoginTooltip")}
style={{ marginLeft: "12px" }}
>
{msg("restartLoginTooltip")}
</Button>
</div>
<Divider />
</>
);
if (displayRequiredFields) {
return (
<>
{node}
<div style={{ marginBottom: "12px" }}>
<Text type="secondary">
<span style={{ color: "#ff4d4f" }}>*</span> {msg("requiredFields")}
</Text>
</div>
</>
);
}
return node;
})()}
</div>
{/* App-initiated actions should not see warning messages about the need to complete the action during login. */}
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<Alert
message={<span dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />}
type={
message.type === "error"
? "error"
: message.type === "success"
? "success"
: message.type === "warning"
? "warning"
: "info"
}
showIcon
style={{ marginBottom: "24px" }}
/>
)}
</div>
<div style={{ overflowY: "auto", height: isMobile ? "100%" : "unset", maxHeight: isMobile ? "100%" : "calc(100vh / 2.25)", padding: "0 15px" }}>
{children}
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<input type="hidden" name="tryAnotherWay" value="on" />
<div style={{ textAlign: "center", margin: "16px 0" }}>
<Button
type="link"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</Button>
</div>
</form>
)}
{displayInfo && <div style={{ marginTop: "24px", textAlign: "center" }}>{infoNode}</div>}
</div>
{enabledLanguages.length > 1 && (
<div style={{ margin: "0 15px"}}>
<Divider style={{ margin: "24px 0" }} />
<Dropdown menu={languageItems} trigger={["click"]}>
<Button style={{ width: "100%", textAlign: "left" }}>
<Space>
{currentLanguage.label}
<GlobalOutlined />
</Space>
</Button>
</Dropdown>
</div>
)} )}
</div></Card></Flex></Content> <Card
style={{
borderRadius: isMobile ? "0px" : "20px",
width: isMobile ? "100vw" : "450px",
zIndex: 1,
height: isMobile ? "100vh" : "unset",
padding: "23px 15px 30px 15px",
boxShadow: "0px 5px 15px 5px rgb(0 0 0 / 10%)"
}}
variant="borderless"
styles={{
body: {
padding: 0,
height: "100%"
}
}}
>
<Flex style={{ height: "100%" }} vertical>
<div style={{ height: "100%", margin: "0 15px" }}>
{isMobile && (
<>
<img
src="https://cdn.tombutcher.work/logos/logo-auth.png"
alt="Logo"
style={{ width: "70%", margin: "0" }}
/>{" "}
<Divider style={{ margin: "24px 0" }} />
</>
)}
<div style={{ marginBottom: "10px" }}>
{(() => {
const node = !(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
<Flex gap={"large"} align="center" style={{ paddingBottom: "8px" }}>
{client.attributes.logoUri && (
<img
src={client.attributes.logoUri}
alt={client.name || client.clientId}
style={{ maxHeight: "64px", maxWidth: "100%", marginBottom: "5px" }}
/>
)}
<Title level={2} style={{ marginBottom: "0" }}>
{headerNode}
</Title>
</Flex>
) : (
<>
<div style={{ display: "flex", alignItems: "center", marginBottom: "24px" }}>
<Flex gap={"small"}>
<img
src={"https://cdn.tombutcher.work/icons/auth/c-person.svg"}
width={14}
style={{ marginTop: "3px" }}
/>
<Text strong>{auth.attemptedUsername}</Text>
</Flex>
<Button
type="link"
href={url.loginRestartFlowUrl}
title={msgStr("restartLoginTooltip")}
style={{ marginLeft: "12px" }}
>
{msg("restartLoginTooltip")}
</Button>
</div>
<Divider />
</>
);
if (displayRequiredFields) {
return (
<>
{node}
<div style={{ marginBottom: "12px" }}>
<Text type="secondary">
<span style={{ color: "#ff4d4f" }}>*</span> {msg("requiredFields")}
</Text>
</div>
</>
);
}
return node;
})()}
</div>
{/* App-initiated actions should not see warning messages about the need to complete the action during login. */}
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<Alert
message={<span dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />}
type={
message.type === "error"
? "error"
: message.type === "success"
? "success"
: message.type === "warning"
? "warning"
: "info"
}
showIcon
style={{ marginBottom: "26px" }}
/>
)}
</div>
{children}
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<input type="hidden" name="tryAnotherWay" value="on" />
<div style={{ textAlign: "center", margin: "16px 0" }}>
<Button
type="link"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</Button>
</div>
</form>
)}
{displayInfo && <div style={{ marginTop: "24px", textAlign: "center" }}>{infoNode}</div>}
</Flex>
</Card>
{!isMobile && (
<Flex style={{ zIndex: 1 }} gap={"large"}>
<Text style={{ color: "#ffffff", fontWeight: 700 }}>© 2025</Text>
{enabledLanguages.length > 1 && (
<>
<Text style={{ color: "#ffffff" }}>|</Text>
<Dropdown menu={languageItems} trigger={["hover"]}>
<Text style={{ color: "#ffffff", fontWeight: 700 }}>
<Space>
{currentLanguage.label}
<GlobalOutlined />
</Space>
</Text>
</Dropdown>
</>
)}
</Flex>
)}
</Flex>
</Content>
</Layout> </Layout>
</ConfigProvider> </ConfigProvider>
); );

View File

@ -195,11 +195,16 @@ a.ant-typography,
input:-webkit-autofill, input:-webkit-autofill,
input:-webkit-autofill:hover, input:-webkit-autofill:hover,
input:-webkit-autofill:focus, input:-webkit-autofill:focus,
input:-webkit-autofill:active { input:-webkit-autofill:active,
input:-internal-autofill-selected {
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: #ffffff; -webkit-text-fill-color: #7e8500;
transition: background-color 5000s ease-in-out 0s; box-shadow: inset 0 0 20px 20px #ffffff !important;
box-shadow: inset 0 0 20px 20px #23232329; background-color: green !important
}
input[type="password"] {
letter-spacing: 5px;
} }
.ant-form-item:last-child { .ant-form-item:last-child {
@ -215,4 +220,8 @@ input:-webkit-autofill:active {
.ant-alert { .ant-alert {
margin-bottom: 5px; margin-bottom: 5px;
}
.ant-form-item {
margin-bottom: 15px;
} }

View File

@ -282,9 +282,9 @@ h2 span * {
font-family: "Grold-Rounded-Slim" !important; font-family: "Grold-Rounded-Slim" !important;
text-transform: uppercase; text-transform: uppercase;
font-weight: 700; font-weight: 700;
font-size: 40px; font-size: 36px;
line-height: 0.9px; line-height: 0.9px !important;
letter-spacing: 0.02em; letter-spacing: 0.01em;
} }
h1 span { h1 span {

View File

@ -19,34 +19,36 @@ export default function Code(props: PageProps<Extract<KcContext, { pageId: "code
classes={classes} classes={classes}
headerNode={code.success ? msg("codeSuccessTitle") : msg("codeErrorTitle", code.error)} headerNode={code.success ? msg("codeSuccessTitle") : msg("codeErrorTitle", code.error)}
> >
{code.success ? ( <div style={{ margin: "0 15px" }}>
<> {code.success ? (
<Space direction="vertical" size="middle" style={{ width: "100%" }}> <>
<Space> <Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Paragraph style={{ margin: 0 }}>{msg("copyCodeInstruction")}</Paragraph> <Space>
</Space> <Paragraph style={{ margin: 0 }}>{msg("copyCodeInstruction")}</Paragraph>
</Space>
<Text strong code style={{ fontSize: "18px" }}> <Text strong code style={{ fontSize: "18px" }}>
{code.code} {code.code}
</Text> </Text>
</Space> </Space>
</> </>
) : ( ) : (
code.error && ( code.error && (
<Alert <Alert
type="error" type="error"
showIcon showIcon
message={ message={
<div <div
id="error" id="error"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: kcSanitize(code.error) __html: kcSanitize(code.error)
}} }}
/> />
} }
/> />
) )
)} )}
</div>
</Template> </Template>
); );
} }

View File

@ -1,4 +1,4 @@
import { Form, Button, Typography, Alert, Divider, List, Flex, FormProps } from "antd"; import { Form, Button, Typography, Alert, List, Flex, FormProps } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
@ -53,7 +53,7 @@ export default function DeleteAccountConfirm(props: PageProps<Extract<KcContext,
return ( return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("deleteAccountConfirm")}> <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("deleteAccountConfirm")}>
<Form form={form} layout="vertical" onFinish={handleSubmit} style={{ width: "100%" }}> <Form form={form} layout="vertical" onFinish={handleSubmit} style={{ width: "100%", padding: "0 15px" }}>
<Alert message={msg("irreversibleAction")} type="warning" showIcon style={{ marginBottom: 16 }} /> <Alert message={msg("irreversibleAction")} type="warning" showIcon style={{ marginBottom: 16 }} />
<Text>{msg("deletingImplies")}</Text> <Text>{msg("deletingImplies")}</Text>
@ -72,9 +72,8 @@ export default function DeleteAccountConfirm(props: PageProps<Extract<KcContext,
<Text strong style={{ marginTop: 8 }}> <Text strong style={{ marginTop: 8 }}>
{msg("finalDeletionConfirmation")} {msg("finalDeletionConfirmation")}
</Text> </Text>
<Divider />
<Flex gap={"middle"}> <Flex gap={"middle"} style={{ marginTop: "20px" }}>
<Button <Button
type="primary" type="primary"
style={{ flexGrow: 2 }} style={{ flexGrow: 2 }}

View File

@ -1,4 +1,4 @@
import { Button, Flex, Alert, Divider } from "antd"; import { Button, Flex, Alert } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
@ -17,35 +17,36 @@ export default function DeleteCredential(props: PageProps<Extract<KcContext, { p
displayMessage={false} displayMessage={false}
headerNode={msg("deleteCredentialTitle", credentialLabel)} headerNode={msg("deleteCredentialTitle", credentialLabel)}
> >
<Alert <div style={{ margin: "0 15px" }}>
message={msg("deleteCredentialMessage", credentialLabel)} <Alert
type="warning" message={msg("deleteCredentialMessage", credentialLabel)}
showIcon type="warning"
style={{ showIcon
marginBottom: 24 style={{
}} marginBottom: 26
/> }}
<Divider /> />
<form action={url.loginAction} method="POST"> <form action={url.loginAction} method="POST">
<Flex gap="middle"> <Flex gap="middle">
<Button <Button
type="primary" type="primary"
style={{ flexGrow: 2 }} style={{ flexGrow: 2 }}
danger danger
size="large" size="large"
htmlType="submit" htmlType="submit"
name="accept" name="accept"
icon={<img src={"/w-bin.svg"} width={14} style={{ marginTop: "0px", marginBottom: "3px" }} />} icon={<img src={"/w-bin.svg"} width={14} style={{ marginTop: "0px", marginBottom: "3px" }} />}
id="kc-accept" id="kc-accept"
> >
{msgStr("doConfirmDelete")} {msgStr("doConfirmDelete")}
</Button> </Button>
<Button size="large" style={{ flexGrow: 1 }} htmlType="submit" name="cancel-aia" id="kc-decline"> <Button size="large" style={{ flexGrow: 1 }} htmlType="submit" name="cancel-aia" id="kc-decline">
{msgStr("doCancel")} {msgStr("doCancel")}
</Button> </Button>
</Flex> </Flex>
</form> </form>
</div>
</Template> </Template>
); );
} }

View File

@ -2,9 +2,7 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
import { Alert, Button, Typography, Space, Divider } from "antd"; import { Alert, Button, Space} from "antd";
const { Title } = Typography;
export default function Error(props: PageProps<Extract<KcContext, { pageId: "error.ftl" }>, I18n>) { export default function Error(props: PageProps<Extract<KcContext, { pageId: "error.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -18,19 +16,18 @@ export default function Error(props: PageProps<Extract<KcContext, { pageId: "err
doUseDefaultCss={doUseDefaultCss} doUseDefaultCss={doUseDefaultCss}
classes={classes} classes={classes}
displayMessage={false} displayMessage={false}
headerNode={<Title level={2}>{msg("errorTitle")}</Title>} headerNode={msg("errorTitle")}
> >
<Space direction="vertical" size="middle"> <div style={{ margin: "0 15px" }}>
<Alert message={<div dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />} type="error" showIcon /> <Space direction="vertical" size="middle" style={{ marginBottom: 24 }}>
</Space> <Alert message={<div dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />} type="error" showIcon />
{!skipLink && client !== undefined && client.baseUrl !== undefined && ( </Space>
<> {!skipLink && client !== undefined && client.baseUrl !== undefined && (
<Divider />
<Button type="primary" id="backToApplication" size={"large"} block href={client.baseUrl}> <Button type="primary" id="backToApplication" size={"large"} block href={client.baseUrl}>
{msg("backToApplication")} {msg("backToApplication")}
</Button> </Button>
</> )}
)} </div>
</Template> </Template>
); );
} }

View File

@ -24,9 +24,9 @@ export default function FrontchannelLogout(props: PageProps<Extract<KcContext, {
doUseDefaultCss={doUseDefaultCss} doUseDefaultCss={doUseDefaultCss}
classes={classes} classes={classes}
documentTitle={msgStr("frontchannel-logout.title")} documentTitle={msgStr("frontchannel-logout.title")}
headerNode={<Title level={3}>{msg("frontchannel-logout.title")}</Title>} headerNode={msg("frontchannel-logout.title")}
> >
<Space direction="vertical" style={{ width: "100%" }}> <Space direction="vertical" style={{ width: "100%", padding: "0 15px" }}>
<Paragraph>{msg("frontchannel-logout.message")}</Paragraph> <Paragraph>{msg("frontchannel-logout.message")}</Paragraph>
<List <List

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Form, Button, Space, Divider } from "antd"; import { Form, Button, Space } from "antd";
import type { JSX } from "keycloakify/tools/JSX"; import type { JSX } from "keycloakify/tools/JSX";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot"; import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
@ -7,6 +7,7 @@ import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFo
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
import { CheckOutlined } from "@ant-design/icons"; import { CheckOutlined } from "@ant-design/icons";
import { useMediaQuery } from "react-responsive";
type IdpReviewUserProfileProps = PageProps<Extract<KcContext, { pageId: "idp-review-user-profile.ftl" }>, I18n> & { type IdpReviewUserProfileProps = PageProps<Extract<KcContext, { pageId: "idp-review-user-profile.ftl" }>, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>; UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
@ -17,6 +18,7 @@ export default function IdpReviewUserProfile(props: IdpReviewUserProfileProps) {
const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields, doMakeUserConfirmPassword } = props; const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields, doMakeUserConfirmPassword } = props;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const { url, messagesPerField } = kcContext; const { url, messagesPerField } = kcContext;
const isMobile = useMediaQuery({ maxWidth: 600 });
const [isFormSubmittable, setIsFormSubmittable] = useState(false); const [isFormSubmittable, setIsFormSubmittable] = useState(false);
return ( return (
@ -26,11 +28,13 @@ export default function IdpReviewUserProfile(props: IdpReviewUserProfileProps) {
doUseDefaultCss={doUseDefaultCss} doUseDefaultCss={doUseDefaultCss}
classes={classes} classes={classes}
displayMessage={messagesPerField.exists("global")} displayMessage={messagesPerField.exists("global")}
displayRequiredFields
headerNode={msg("loginIdpReviewProfileTitle")} headerNode={msg("loginIdpReviewProfileTitle")}
> >
<Form id="kc-idp-review-profile-form" layout="vertical" method="post" action={url.loginAction} size="large"> <Form id="kc-idp-review-profile-form" layout="vertical" method="post" action={url.loginAction} size="large">
<div className="kctbform" style={{ paddingBottom: 0 }}> <div
className="kctbform"
style={{ paddingBottom: 0, overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px", marginBottom: 24 }}
>
<UserProfileFormFields <UserProfileFormFields
kcContext={kcContext} kcContext={kcContext}
i18n={i18n} i18n={i18n}
@ -39,8 +43,7 @@ export default function IdpReviewUserProfile(props: IdpReviewUserProfileProps) {
doMakeUserConfirmPassword={doMakeUserConfirmPassword} doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/> />
</div> </div>
<Divider /> <Space direction="vertical" style={{ width: "100%", padding: "0 15px" }}>
<Space direction="vertical" style={{ width: "100%" }}>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" icon={<CheckOutlined />} block size="large" disabled={!isFormSubmittable}> <Button type="primary" htmlType="submit" icon={<CheckOutlined />} block size="large" disabled={!isFormSubmittable}>
{msgStr("doSubmit")} {msgStr("doSubmit")}

View File

@ -1,9 +1,9 @@
import { Typography, Button, Space, Divider } from "antd"; import { Typography, Button, Space } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const { Title, Text } = Typography; const { Text } = Typography;
export default function Info(props: PageProps<Extract<KcContext, { pageId: "info.ftl" }>, I18n>) { export default function Info(props: PageProps<Extract<KcContext, { pageId: "info.ftl" }>, I18n>) {
const { kcContext, i18n, Template } = props; const { kcContext, i18n, Template } = props;
@ -29,20 +29,16 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
if (pageRedirectUri) { if (pageRedirectUri) {
return ( return (
<> <Button type="primary" block size={"large"} href={pageRedirectUri} style={{ marginTop: 24 }}>
<Divider /> {msg("backToApplication")}
<Button type="primary" block size={"large"} href={pageRedirectUri}> </Button>
{msg("backToApplication")}
</Button>
</>
); );
} }
if (actionUri) { if (actionUri) {
return ( return (
<> <>
<Divider /> <Button type="primary" block size={"large"} href={actionUri} style={{ marginTop: 24 }}>
<Button type="primary" block size={"large"} href={actionUri}>
{msg("proceedWithAction")} {msg("proceedWithAction")}
</Button> </Button>
</> </>
@ -52,8 +48,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
if (client.baseUrl) { if (client.baseUrl) {
return ( return (
<> <>
<Divider /> <Button type="primary" block size={"large"} href={client.baseUrl} style={{ marginTop: 24 }}>
<Button type="primary" block size={"large"} href={client.baseUrl}>
{msg("backToApplication")} {msg("backToApplication")}
</Button> </Button>
</> </>
@ -64,19 +59,16 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
}; };
return ( return (
<Template <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} displayMessage={false} headerNode={messageHeader}>
kcContext={kcContext} <div style={{ padding: "0 15px" }}>
i18n={i18n} <Space direction="vertical" size="middle">
doUseDefaultCss={false} <Text className="instruction">
displayMessage={false} <span dangerouslySetInnerHTML={{ __html: getMessageContent() }}></span>
headerNode={<Title level={3}>{messageHeader}</Title>} </Text>
> </Space>
<Space direction="vertical" size="middle">
<Text className="instruction"> {renderActionLink()}
<span dangerouslySetInnerHTML={{ __html: getMessageContent() }}></span> </div>
</Text>
</Space>
{renderActionLink()}
</Template> </Template>
); );
} }

View File

@ -68,22 +68,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
kcContext={kcContext} kcContext={kcContext}
i18n={i18n} i18n={i18n}
doUseDefaultCss={false} doUseDefaultCss={false}
headerNode={ headerNode={msg("loginAccountTitle")}
<>
{client.attributes.logoUri ? (
<Space align="start" direction="vertical">
<img
src={client.attributes.logoUri}
alt={client.name || client.clientId}
style={{ maxHeight: "64px", maxWidth: "100%", marginBottom: "20px" }}
/>
<Title level={3}>{msg("loginAccountTitle")}</Title>
</Space>
) : (
<Title level={3}>{msg("loginAccountTitle")}</Title>
)}
</>
}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled} displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
infoNode={ infoNode={
<Space> <Space>
@ -123,6 +108,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
form={form} form={form}
initialValues={{ username: login.username || "", rememberMe: !!login.rememberMe }} initialValues={{ username: login.username || "", rememberMe: !!login.rememberMe }}
layout="vertical" layout="vertical"
style={{ padding: "0 15px" }}
requiredMark={false} requiredMark={false}
onFinish={onFinish} onFinish={onFinish}
> >
@ -154,7 +140,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
/> />
</Form.Item> </Form.Item>
<Row justify="space-between" align="middle"> <Row justify="space-between" align="middle" style={{ paddingTop: "4px" }}>
{realm.rememberMe && !usernameHidden && ( {realm.rememberMe && !usernameHidden && (
<Col> <Col>
<Form.Item name="rememberMe" valuePropName="checked" noStyle> <Form.Item name="rememberMe" valuePropName="checked" noStyle>
@ -171,7 +157,6 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
</Col> </Col>
)} )}
</Row> </Row>
<Divider />
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"
@ -183,6 +168,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
disabled={isLoading} disabled={isLoading}
loading={isLoading} loading={isLoading}
iconPosition={"end"} iconPosition={"end"}
style={{ marginTop: 24 }}
icon={ icon={
<img <img
src={"https://cdn.tombutcher.work/icons/auth/w-right.svg"} src={"https://cdn.tombutcher.work/icons/auth/w-right.svg"}

View File

@ -5,6 +5,7 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
import { Typography, List, Button, Form, Input, Checkbox, Space, Steps, Divider, Flex } from "antd"; import { Typography, List, Button, Form, Input, Checkbox, Space, Steps, Divider, Flex } from "antd";
import { useMediaQuery } from "react-responsive";
const { Title, Text, Paragraph, Link } = Typography; const { Title, Text, Paragraph, Link } = Typography;
@ -67,6 +68,8 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
onFinish({ "cancel-aia": "true" }); onFinish({ "cancel-aia": "true" });
}; };
const isMobile = useMediaQuery({ maxWidth: 600 });
return ( return (
<Template <Template
kcContext={kcContext} kcContext={kcContext}
@ -76,208 +79,210 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
headerNode={<Title level={3}>{msg("loginTotpTitle")}</Title>} headerNode={<Title level={3}>{msg("loginTotpTitle")}</Title>}
displayMessage={!messagesPerField.existsError("totp", "userLabel")} displayMessage={!messagesPerField.existsError("totp", "userLabel")}
> >
<Steps <Form layout="vertical" onFinish={handleSubmit} style={{ margin: "0 auto" }}>
direction="vertical" <div style={{ overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px", marginBottom: 24 }}>
current={3} <Steps
items={[ direction="vertical"
{ current={3}
title: msg("loginTotpStep1"), items={[
icon: <img src={"https://cdn.tombutcher.work/icons/auth/c-download-128.svg"} height={14} />, {
description: ( title: msg("loginTotpStep1"),
<div style={{ marginBottom: 24 }}> icon: <img src={"https://cdn.tombutcher.work/icons/auth/c-download-128.svg"} height={14} />,
<Paragraph>{msg("loginTotpStep1")}</Paragraph> description: (
<List
id="kc-totp-supported-apps"
size="small"
bordered
dataSource={totp.supportedApplications}
renderItem={app => (
<List.Item>
<Space>
<img
src={"https://cdn.tombutcher.work/icons/auth/c-phone.svg"}
width={14}
style={{ marginRight: 8 }}
/>
{advancedMsg(app)}
</Space>
</List.Item>
)}
/>
</div>
)
},
{
title: mode === "manual" ? msg("loginTotpManualStep2") : msg("loginTotpStep2"),
icon: <img src={"https://cdn.tombutcher.work/icons/auth/c-qrcode-128.svg"} height={14} />,
description: (
<>
{mode === "manual" ? (
<div style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
<Paragraph>{msg("loginTotpManualStep2")}</Paragraph> <Paragraph>{msg("loginTotpStep1")}</Paragraph>
<Paragraph> <List
<Text code>{totp.totpSecretEncoded}</Text> id="kc-totp-supported-apps"
</Paragraph> size="small"
<Paragraph> bordered
<Link href={totp.qrUrl} id="mode-barcode"> dataSource={totp.supportedApplications}
{msg("loginTotpScanBarcode")} renderItem={app => (
</Link> <List.Item>
</Paragraph> <Space>
<Divider /> <img
<Paragraph>{msg("loginTotpManualStep3")}</Paragraph> src={"https://cdn.tombutcher.work/icons/auth/c-phone.svg"}
<List size="small" bordered> width={14}
<List.Item id="kc-totp-type"> style={{ marginRight: 8 }}
<Text strong>{msg("loginTotpType")}:</Text> {msg(`loginTotp.${totp.policy.type}`)} />
</List.Item> {advancedMsg(app)}
<List.Item id="kc-totp-algorithm"> </Space>
<Text strong>{msg("loginTotpAlgorithm")}:</Text> {totp.policy.getAlgorithmKey()}
</List.Item>
<List.Item id="kc-totp-digits">
<Text strong>{msg("loginTotpDigits")}:</Text> {totp.policy.digits}
</List.Item>
{totp.policy.type === "totp" ? (
<List.Item id="kc-totp-period">
<Text strong>{msg("loginTotpInterval")}:</Text> {totp.policy.period}
</List.Item>
) : (
<List.Item id="kc-totp-counter">
<Text strong>{msg("loginTotpCounter")}:</Text> {totp.policy.initialCounter}
</List.Item> </List.Item>
)} )}
</List> />
</div> </div>
) : ( )
<div style={{ marginBottom: 24 }}> },
<Paragraph>{msg("loginTotpStep2")}</Paragraph> {
<div style={{ textAlign: "center", margin: "20px 0" }}> title: mode === "manual" ? msg("loginTotpManualStep2") : msg("loginTotpStep2"),
<img icon: <img src={"https://cdn.tombutcher.work/icons/auth/c-qrcode-128.svg"} height={14} />,
id="kc-totp-secret-qr-code" description: (
src={`data:image/png;base64, ${totp.totpSecretQrCode}`} <>
alt="QR Code" {mode === "manual" ? (
style={{ maxWidth: "200px" }} <div style={{ marginBottom: 24 }}>
/> <Paragraph>{msg("loginTotpManualStep2")}</Paragraph>
</div> <Paragraph>
<Paragraph> <Text code>{totp.totpSecretEncoded}</Text>
<Link href={totp.manualUrl} id="mode-manual"> </Paragraph>
{msg("loginTotpUnableToScan")} <Paragraph>
</Link> <Link href={totp.qrUrl} id="mode-barcode">
</Paragraph> {msg("loginTotpScanBarcode")}
</Link>
</Paragraph>
<Divider />
<Paragraph>{msg("loginTotpManualStep3")}</Paragraph>
<List size="small" bordered>
<List.Item id="kc-totp-type">
<Text strong>{msg("loginTotpType")}:</Text> {msg(`loginTotp.${totp.policy.type}`)}
</List.Item>
<List.Item id="kc-totp-algorithm">
<Text strong>{msg("loginTotpAlgorithm")}:</Text> {totp.policy.getAlgorithmKey()}
</List.Item>
<List.Item id="kc-totp-digits">
<Text strong>{msg("loginTotpDigits")}:</Text> {totp.policy.digits}
</List.Item>
{totp.policy.type === "totp" ? (
<List.Item id="kc-totp-period">
<Text strong>{msg("loginTotpInterval")}:</Text> {totp.policy.period}
</List.Item>
) : (
<List.Item id="kc-totp-counter">
<Text strong>{msg("loginTotpCounter")}:</Text> {totp.policy.initialCounter}
</List.Item>
)}
</List>
</div>
) : (
<div style={{ marginBottom: 24 }}>
<Paragraph>{msg("loginTotpStep2")}</Paragraph>
<div style={{ textAlign: "center", margin: "20px 0" }}>
<img
id="kc-totp-secret-qr-code"
src={`data:image/png;base64, ${totp.totpSecretQrCode}`}
alt="QR Code"
style={{ maxWidth: "200px" }}
/>
</div>
<Paragraph>
<Link href={totp.manualUrl} id="mode-manual">
{msg("loginTotpUnableToScan")}
</Link>
</Paragraph>
</div>
)}
</>
)
},
{
title: msg("loginTotpStep3"),
icon: <img src={"https://cdn.tombutcher.work/icons/auth/c-check-128.svg"} height={14} />,
description: (
<div>
<Paragraph>{msg("loginTotpStep3")}</Paragraph>
<Text>{msg("loginTotpStep3DeviceName")}</Text>
</div> </div>
)} )
</>
)
},
{
title: msg("loginTotpStep3"),
icon: <img src={"https://cdn.tombutcher.work/icons/auth/c-check-128.svg"} height={14} />,
description: (
<div>
<Paragraph>{msg("loginTotpStep3")}</Paragraph>
<Text>{msg("loginTotpStep3DeviceName")}</Text>
</div>
)
}
]}
/>
<Divider />
<Form layout="vertical" onFinish={handleSubmit} style={{ margin: "0 auto" }}>
<Form.Item
label={
<>
{msg("authenticatorCode")} <span className="required">*</span>
</>
}
name="totp"
validateStatus={messagesPerField.existsError("totp") ? "error" : undefined}
help={
messagesPerField.existsError("totp") && (
<span
id="input-error-otp-code"
dangerouslySetInnerHTML={{
__html: kcSanitize(messagesPerField.get("totp"))
}}
/>
)
}
>
<Input
id="totp"
autoComplete="off"
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />}
aria-invalid={messagesPerField.existsError("totp")}
/>
</Form.Item>
<Form.Item
label={
<>
{msg("loginTotpDeviceName")} {totp.otpCredentials.length >= 1 && <span className="required">*</span>}
</>
}
name="userLabel"
validateStatus={messagesPerField.existsError("userLabel") ? "error" : undefined}
help={
messagesPerField.existsError("userLabel") && (
<span
id="input-error-otp-label"
dangerouslySetInnerHTML={{
__html: kcSanitize(messagesPerField.get("userLabel"))
}}
/>
)
}
>
<Input
id="userLabel"
autoComplete="off"
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-phone.svg"} width={14} style={{ marginRight: "3px" }} />}
aria-invalid={messagesPerField.existsError("userLabel")}
/>
</Form.Item>
<Form.Item>
<LogoutOtherSessions kcClsx={kcClsx} i18n={i18n} />
</Form.Item>
<Divider />
<Form.Item>
<Flex gap={"middle"}>
<Button
type="primary"
htmlType="submit"
style={{ flexGrow: 2 }}
size="large"
id="saveTOTPBtn"
iconPosition="end"
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
style={{ marginTop: "0px", marginBottom: "3px" }}
width={14}
/>
} }
loading={isSubmitLoading} ]}
disabled={isCancelLoading || isSubmitLoading} />
> <Divider />
{msgStr("doSubmit")} <Form.Item
</Button> label={
{isAppInitiatedAction ? ( <>
{msg("authenticatorCode")} <span className="required">*</span>
</>
}
name="totp"
validateStatus={messagesPerField.existsError("totp") ? "error" : undefined}
help={
messagesPerField.existsError("totp") && (
<span
id="input-error-otp-code"
dangerouslySetInnerHTML={{
__html: kcSanitize(messagesPerField.get("totp"))
}}
/>
)
}
>
<Input
id="totp"
autoComplete="off"
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />}
aria-invalid={messagesPerField.existsError("totp")}
/>
</Form.Item>
<Form.Item
label={
<>
{msg("loginTotpDeviceName")} {totp.otpCredentials.length >= 1 && <span className="required">*</span>}
</>
}
name="userLabel"
validateStatus={messagesPerField.existsError("userLabel") ? "error" : undefined}
help={
messagesPerField.existsError("userLabel") && (
<span
id="input-error-otp-label"
dangerouslySetInnerHTML={{
__html: kcSanitize(messagesPerField.get("userLabel"))
}}
/>
)
}
>
<Input
id="userLabel"
autoComplete="off"
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-phone.svg"} width={14} style={{ marginRight: "3px" }} />}
aria-invalid={messagesPerField.existsError("userLabel")}
/>
</Form.Item>
</div>
<div style={{ padding: "0 15px" }}>
<Form.Item>
<LogoutOtherSessions kcClsx={kcClsx} i18n={i18n} />
</Form.Item>
<Form.Item>
<Flex gap={"middle"}>
<Button <Button
type="default" type="primary"
htmlType="submit" htmlType="submit"
style={{ flexGrow: 2 }}
size="large" size="large"
style={{ flexGrow: 1 }} id="saveTOTPBtn"
id="cancelTOTPBtn" iconPosition="end"
onClick={handleCancel} icon={
loading={isCancelLoading} <img
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
style={{ marginTop: "0px", marginBottom: "3px" }}
width={14}
/>
}
loading={isSubmitLoading}
disabled={isCancelLoading || isSubmitLoading} disabled={isCancelLoading || isSubmitLoading}
> >
{msg("doCancel")} {msgStr("doSubmit")}
</Button> </Button>
) : null} {isAppInitiatedAction ? (
</Flex> <Button
</Form.Item> type="default"
htmlType="submit"
size="large"
style={{ flexGrow: 1 }}
id="cancelTOTPBtn"
onClick={handleCancel}
loading={isCancelLoading}
disabled={isCancelLoading || isSubmitLoading}
>
{msg("doCancel")}
</Button>
) : null}
</Flex>
</Form.Item>
</div>
</Form> </Form>
</Template> </Template>
); );

View File

@ -1,4 +1,4 @@
import { Button, Divider, Form, Space } from "antd"; import { Button, Form, Space } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
@ -10,8 +10,7 @@ export default function LoginIdpLinkConfirm(props: PageProps<Extract<KcContext,
return ( return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} classes={{}} headerNode={msg("confirmLinkIdpTitle")}> <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} classes={{}} headerNode={msg("confirmLinkIdpTitle")}>
<Divider /> <Space direction="vertical" size="large" style={{ width: "100%", padding: "0 15px" }}>
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Form id="kc-register-form" action={url.loginAction} method="post"> <Form id="kc-register-form" action={url.loginAction} method="post">
<Space direction="vertical" size="middle" style={{ width: "100%" }}> <Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Button type="default" htmlType="submit" id="updateProfile" name="submitAction" value="updateProfile" block size="large"> <Button type="default" htmlType="submit" id="updateProfile" name="submitAction" value="updateProfile" block size="large">

View File

@ -23,7 +23,7 @@ export default function LoginIdpLinkEmail(props: PageProps<Extract<KcContext, {
</Space> </Space>
} }
> >
<Space direction="vertical" size="large" style={{ width: "100%" }}> <Space direction="vertical" size="large" style={{ width: "100%", padding: "0 15px" }}>
<Text id="instruction1" className="instruction"> <Text id="instruction1" className="instruction">
<span style={{ fontWeight: "800" }}>{msg("emailLinkIdp1", idpAlias, brokerContext.username, realm.displayName)}</span> <span style={{ fontWeight: "800" }}>{msg("emailLinkIdp1", idpAlias, brokerContext.username, realm.displayName)}</span>
</Text> </Text>

View File

@ -1,9 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Divider, Form, Input } from "antd"; import { Button, Typography, Form, Input } from "antd";
import { PageProps } from "keycloakify/login/pages/PageProps"; import { PageProps } from "keycloakify/login/pages/PageProps";
import { KcContext } from "../KcContext"; import { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const { Text } = Typography;
export default function LoginOauth2DeviceVerifyUserCode( export default function LoginOauth2DeviceVerifyUserCode(
props: PageProps<Extract<KcContext, { pageId: "login-oauth2-device-verify-user-code.ftl" }>, I18n> props: PageProps<Extract<KcContext, { pageId: "login-oauth2-device-verify-user-code.ftl" }>, I18n>
) { ) {
@ -42,18 +44,20 @@ export default function LoginOauth2DeviceVerifyUserCode(
return ( return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} classes={{}} headerNode={msg("oauth2DeviceVerificationTitle")}> <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} classes={{}} headerNode={msg("oauth2DeviceVerificationTitle")}>
<Form id="kc-user-verify-device-user-code-form" layout="vertical" onFinish={handleSubmit}> <Form id="kc-user-verify-device-user-code-form" layout="vertical" onFinish={handleSubmit} style={{ padding: "0 15px" }}>
<Form.Item label={msg("verifyOAuth2DeviceUserCode")} name="device_user_code" rules={[{ required: true, message: "Required" }]}> <Text>{msg("verifyOAuth2DeviceUserCode")}</Text>
<Form.Item label={"Code"} name="device_user_code" rules={[{ required: true, message: "Required" }]}>
<Input <Input
id="device-user-code" id="device-user-code"
name="device_user_code" name="device_user_code"
style={{ marginBottom: "10px" }}
autoComplete="off" autoComplete="off"
autoFocus autoFocus
size="large" size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />} prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />}
/> />
</Form.Item> </Form.Item>
<Divider />
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"

View File

@ -4,7 +4,7 @@ import { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
import { Typography, Space, List, Button, Flex, Divider, Alert } from "antd"; import { Typography, Space, List, Button, Flex, Divider, Alert } from "antd";
const { Title, Text, Link } = Typography; const { Text, Link } = Typography;
export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pageId: "login-oauth-grant.ftl" }>, I18n>) { export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pageId: "login-oauth-grant.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props; const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
@ -47,124 +47,106 @@ export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pa
doUseDefaultCss={doUseDefaultCss} doUseDefaultCss={doUseDefaultCss}
classes={classes} classes={classes}
bodyClassName="oauth" bodyClassName="oauth"
headerNode={ headerNode={client.name ? msg("oauthGrantTitle", advancedMsgStr(client.name)) : msg("oauthGrantTitle", client.clientId)}
<>
{client.attributes.logoUri ? (
<Space align="start" direction="vertical">
<img
src={client.attributes.logoUri}
alt={client.name || client.clientId}
style={{ maxHeight: "64px", maxWidth: "100%", marginBottom: "20px" }}
/>
<Title level={3}>
{client.name ? msg("oauthGrantTitle", advancedMsgStr(client.name)) : msg("oauthGrantTitle", client.clientId)}
</Title>
</Space>
) : (
<Title level={3}>
{client.name ? msg("oauthGrantTitle", advancedMsgStr(client.name)) : msg("oauthGrantTitle", client.clientId)}
</Title>
)}
</>
}
> >
<Alert message={msg("oauthGrantRequest")} type="warning" style={{ marginBottom: "20px" }} showIcon></Alert> <div style={{ margin: "0 15px" }}>
{(client.attributes.policyUri || client.attributes.tosUri) && ( <Alert message={msg("oauthGrantRequest")} type="warning" style={{ marginBottom: "20px" }} showIcon></Alert>
<Alert {(client.attributes.policyUri || client.attributes.tosUri) && (
message={client.name ? msg("oauthGrantInformation", advancedMsgStr(client.name)) : msg("oauthGrantInformation", client.clientId)} <Alert
type="info" message={
style={{ marginBottom: "20px" }} client.name ? msg("oauthGrantInformation", advancedMsgStr(client.name)) : msg("oauthGrantInformation", client.clientId)
showIcon }
></Alert> type="info"
)} style={{ marginBottom: "20px" }}
{oauth.clientScopesRequested.length > 0 && ( showIcon
<List ></Alert>
dataSource={oauth.clientScopesRequested} )}
bordered={true} {oauth.clientScopesRequested.length > 0 && (
renderItem={clientScope => ( <List
<List.Item key={clientScope.consentScreenText}> dataSource={oauth.clientScopesRequested}
<Space> bordered={true}
renderItem={clientScope => (
<List.Item key={clientScope.consentScreenText}>
<Space>
<img
src={"https://cdn.tombutcher.work/icons/auth/c-check-128.svg"}
style={{ marginTop: 0, marginBottom: "3px" }}
width={14}
/>
<Text>
{advancedMsg(clientScope.consentScreenText)}
{clientScope.dynamicScopeParameter && (
<>
: <Text strong>{clientScope.dynamicScopeParameter}</Text>
</>
)}
</Text>
</Space>
</List.Item>
)}
/>
)}
{(client.attributes.policyUri || client.attributes.tosUri) && (
<div style={{ marginTop: 10 }}>
<Space direction="vertical" style={{ marginTop: "12px" }}>
{client.attributes.tosUri && (
<Text>
{msg("oauthGrantReview")}{" "}
<Link href={client.attributes.tosUri} target="_blank">
{msg("oauthGrantTos")}
</Link>
</Text>
)}
{client.attributes.policyUri && (
<Text>
{msg("oauthGrantReview")}{" "}
<Link href={client.attributes.policyUri} target="_blank">
{msg("oauthGrantPolicy")}
</Link>
</Text>
)}
</Space>
</div>
)}
<Flex gap="middle" style={{ marginTop: 24 }}>
<Button
type="primary"
style={{ flexGrow: 1 }}
size="large"
id="kc-login"
loading={isAcceptLoading}
disabled={isAcceptLoading || isDeclineLoading}
iconPosition="end"
icon={
<img <img
src={"https://cdn.tombutcher.work/icons/auth/c-check-128.svg"} src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
style={{ marginTop: 0, marginBottom: "3px" }} style={{ marginTop: "0px", marginBottom: "3px" }}
width={14} width={14}
/> />
<Text> }
{advancedMsg(clientScope.consentScreenText)} onClick={() => {
{clientScope.dynamicScopeParameter && ( setIsAcceptLoading(true);
<> handleSubmit("accept");
: <Text strong>{clientScope.dynamicScopeParameter}</Text> }}
</> >
)} {msgStr("doYes")}
</Text> </Button>
</Space> <Button
</List.Item> type="default"
)} style={{ flexGrow: 1 }}
/> size="large"
)} id="kc-cancel"
{(client.attributes.policyUri || client.attributes.tosUri) && ( loading={isDeclineLoading}
<> disabled={isAcceptLoading || isDeclineLoading}
<Space direction="vertical" style={{ marginTop: "12px" }}> onClick={() => {
{client.attributes.tosUri && ( setIsDeclineLoading(true);
<Text> handleSubmit("cancel");
{msg("oauthGrantReview")}{" "} }}
<Link href={client.attributes.tosUri} target="_blank"> >
{msg("oauthGrantTos")} {msgStr("doNo")}
</Link> </Button>
</Text> </Flex>
)} </div>
{client.attributes.policyUri && (
<Text>
{msg("oauthGrantReview")}{" "}
<Link href={client.attributes.policyUri} target="_blank">
{msg("oauthGrantPolicy")}
</Link>
</Text>
)}
</Space>
</>
)}
<Divider />
<Flex gap="middle">
<Button
type="primary"
style={{ flexGrow: 1 }}
size="large"
id="kc-login"
loading={isAcceptLoading}
disabled={isAcceptLoading || isDeclineLoading}
iconPosition="end"
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
style={{ marginTop: "0px", marginBottom: "3px" }}
width={14}
/>
}
onClick={() => {
setIsAcceptLoading(true);
handleSubmit("accept");
}}
>
{msgStr("doYes")}
</Button>
<Button
type="default"
style={{ flexGrow: 1 }}
size="large"
id="kc-cancel"
loading={isDeclineLoading}
disabled={isAcceptLoading || isDeclineLoading}
onClick={() => {
setIsDeclineLoading(true);
handleSubmit("cancel");
}}
>
{msgStr("doNo")}
</Button>
</Flex>
</Template> </Template>
); );
} }

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Form, Input, Button, Radio, List, Typography, Space, Divider } from "antd"; import { Form, Input, Button, Radio, List, Typography, Space } from "antd";
import { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
@ -51,7 +51,7 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
displayMessage={!messagesPerField.existsError("totp")} displayMessage={!messagesPerField.existsError("totp")}
headerNode={msg("doLogIn")} headerNode={msg("doLogIn")}
> >
<Space direction="vertical" size="large" style={{ width: "100%" }}> <Space direction="vertical" size="large" style={{ width: "100%", padding: "0 15px" }}>
<Form id="kc-otp-login-form" layout="vertical" onFinish={handleSubmit}> <Form id="kc-otp-login-form" layout="vertical" onFinish={handleSubmit}>
{otpLogin.userOtpCredentials.length > 1 && ( {otpLogin.userOtpCredentials.length > 1 && (
<Form.Item name="selectedCredentialId"> <Form.Item name="selectedCredentialId">
@ -96,7 +96,6 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />} prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />}
/> />
</Form.Item> </Form.Item>
<Divider />
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"
@ -107,6 +106,7 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
size="large" size="large"
disabled={isSubmitLoading} disabled={isSubmitLoading}
loading={isSubmitLoading} loading={isSubmitLoading}
style={{ marginTop: 10 }}
iconPosition={"end"} iconPosition={"end"}
icon={ icon={
<img <img

View File

@ -3,7 +3,7 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const { Title, Text, Link } = Typography; const { Text, Link } = Typography;
export default function LoginPageExpired(props: PageProps<Extract<KcContext, { pageId: "login-page-expired.ftl" }>, I18n>) { export default function LoginPageExpired(props: PageProps<Extract<KcContext, { pageId: "login-page-expired.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -11,14 +11,8 @@ export default function LoginPageExpired(props: PageProps<Extract<KcContext, { p
const { msg } = i18n; const { msg } = i18n;
return ( return (
<Template <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("pageExpiredTitle")}>
kcContext={kcContext} <Space direction="vertical" size="small" style={{ width: "100%", margin: "0 15px" }}>
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
headerNode={<Title level={3}>{msg("pageExpiredTitle")}</Title>}
>
<Space direction="vertical" size="small" style={{ width: "100%" }}>
<Text id="instruction1"> <Text id="instruction1">
{msg("pageExpiredMsg1")}{" "} {msg("pageExpiredMsg1")}{" "}
<Link id="loginRestartLink" href={url.loginRestartFlowUrl}> <Link id="loginRestartLink" href={url.loginRestartFlowUrl}>

View File

@ -1,19 +1,19 @@
import { useState } from "react"; import { useState } from "react";
import { Form, Input, Button, Divider, Typography, Space, FormProps } from "antd"; import { Form, Input, Button, Typography, FormProps } from "antd";
import { EyeTwoTone, EyeInvisibleOutlined } from "@ant-design/icons"; import { EyeTwoTone, EyeInvisibleOutlined } from "@ant-design/icons";
import { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const { Title, Link } = Typography; const { Link } = Typography;
export default function LoginPassword(props: PageProps<Extract<KcContext, { pageId: "login-password.ftl" }>, I18n>) { export default function LoginPassword(props: PageProps<Extract<KcContext, { pageId: "login-password.ftl" }>, I18n>) {
const { kcContext, i18n, Template } = props; const { kcContext, i18n, Template } = props;
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
const { realm, url, messagesPerField, client } = kcContext; const { realm, url, messagesPerField } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
type FieldType = { type FieldType = {
@ -47,25 +47,10 @@ export default function LoginPassword(props: PageProps<Extract<KcContext, { page
kcContext={kcContext} kcContext={kcContext}
i18n={i18n} i18n={i18n}
doUseDefaultCss={false} doUseDefaultCss={false}
headerNode={ headerNode={msg("loginAccountTitle")}
<>
{client.attributes.logoUri ? (
<Space align="start" direction="vertical">
<img
src={client.attributes.logoUri}
alt={client.name || client.clientId}
style={{ maxHeight: "64px", maxWidth: "100%", marginBottom: "20px" }}
/>
<Title level={3}>{msg("loginAccountTitle")}</Title>
</Space>
) : (
<Title level={3}>{msg("loginAccountTitle")}</Title>
)}
</>
}
displayMessage={!messagesPerField.existsError("password")} displayMessage={!messagesPerField.existsError("password")}
> >
<Form form={form} layout="vertical" requiredMark={false} onFinish={onFinish}> <Form form={form} layout="vertical" requiredMark={false} onFinish={onFinish} style={{ padding: "0 15px" }}>
<Form.Item <Form.Item
name="password" name="password"
label={msg("password")} label={msg("password")}
@ -92,13 +77,11 @@ export default function LoginPassword(props: PageProps<Extract<KcContext, { page
</Form.Item> </Form.Item>
{realm.resetPasswordAllowed && ( {realm.resetPasswordAllowed && (
<div style={{ textAlign: "right", marginBottom: 16 }}> <div style={{ textAlign: "right", paddingTop: "2px" }}>
<Link href={url.loginResetCredentialsUrl}>{msg("doForgotPassword")}</Link> <Link href={url.loginResetCredentialsUrl}>{msg("doForgotPassword")}</Link>
</div> </div>
)} )}
<Divider />
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"
@ -110,6 +93,7 @@ export default function LoginPassword(props: PageProps<Extract<KcContext, { page
disabled={isLoading} disabled={isLoading}
loading={isLoading} loading={isLoading}
iconPosition="end" iconPosition="end"
style={{ marginTop: 24 }}
icon={ icon={
<img <img
src={"https://cdn.tombutcher.work/icons/auth/w-right.svg"} src={"https://cdn.tombutcher.work/icons/auth/w-right.svg"}

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Alert, Flex, Button, Checkbox, Form, Input, Space, Typography, Divider } from "antd"; import { Alert, Flex, Button, Checkbox, Form, Input, Space, Typography } from "antd";
import { PrinterOutlined, SaveOutlined, CopyOutlined } from "@ant-design/icons"; import { PrinterOutlined, SaveOutlined, CopyOutlined } from "@ant-design/icons";
import { useScript } from "keycloakify/login/pages/LoginRecoveryAuthnCodeConfig.useScript"; import { useScript } from "keycloakify/login/pages/LoginRecoveryAuthnCodeConfig.useScript";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
@ -32,65 +32,71 @@ export default function LoginRecoveryAuthnCodeConfig(props: PageProps<Extract<Kc
classes={classes} classes={classes}
headerNode={msg("recovery-code-config-header")} headerNode={msg("recovery-code-config-header")}
> >
<Space direction="vertical" size="middle"> <div style={{ margin: "0 15px" }}>
<Space direction="vertical" size="large"> <Space direction="vertical" size="middle">
<Alert message={msg("recovery-code-config-warning-title")} type="warning" showIcon /> <Space direction="vertical" size="large">
<Text>{msg("recovery-code-config-warning-message")}</Text> <Alert message={msg("recovery-code-config-warning-title")} type="warning" showIcon />
</Space> <Text>{msg("recovery-code-config-warning-message")}</Text>
<Flex id={olRecoveryCodesListId} gap="middle" wrap> </Space>
{recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList.map((code, index) => ( <Flex id={olRecoveryCodesListId} gap="middle" wrap>
<Text key={index} strong code style={{ fontSize: "18px" }}> {recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList.map((code, index) => (
{formatRecoveryCode(code)} <Text key={index} strong code style={{ fontSize: "18px" }}>
</Text> {formatRecoveryCode(code)}
))} </Text>
</Flex> ))}
</Flex>
<Space>
<Button type="link" icon={<PrinterOutlined />} id="printRecoveryCodes">
{msg("recovery-codes-print")}
</Button>
<Button type="link" icon={<SaveOutlined />} id="downloadRecoveryCodes">
{msg("recovery-codes-download")}
</Button>
<Button type="link" icon={<CopyOutlined />} id="copyRecoveryCodes">
{msg("recovery-codes-copy")}
</Button>
</Space>
<Checkbox
id="kcRecoveryCodesConfirmationCheck"
name="kcRecoveryCodesConfirmationCheck"
onChange={e => setIsConfirmed(e.target.checked)}
style={{ marginBottom: "0px" }}
>
{msg("recovery-codes-confirmation-message")}
</Checkbox>
</Space>
<Form id="kc-recovery-codes-settings-form" method="post" action={kcContext.url.loginAction}>
<Input type="hidden" name="generatedRecoveryAuthnCodes" value={recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString} />
<Input type="hidden" name="generatedAt" value={recoveryAuthnCodesConfigBean.generatedAt} />
<Input type="hidden" id="userLabel" name="userLabel" value={msgStr("recovery-codes-label-default")} />
<LogoutOtherSessions i18n={i18n} />
<Divider />
{isAppInitiatedAction ? (
<Space> <Space>
<Button type="primary" size="large" id="saveRecoveryAuthnCodesBtn" htmlType="submit" disabled={!isConfirmed}> <Button type="link" icon={<PrinterOutlined />} id="printRecoveryCodes">
{msg("recovery-codes-action-complete")} {msg("recovery-codes-print")}
</Button> </Button>
<Button size="large" id="cancelRecoveryAuthnCodesBtn" name="cancel-aia" value="true" htmlType="submit"> <Button type="link" icon={<SaveOutlined />} id="downloadRecoveryCodes">
{msg("recovery-codes-action-cancel")} {msg("recovery-codes-download")}
</Button>
<Button type="link" icon={<CopyOutlined />} id="copyRecoveryCodes">
{msg("recovery-codes-copy")}
</Button> </Button>
</Space> </Space>
) : (
<Button type="primary" size="large" id="saveRecoveryAuthnCodesBtn" htmlType="submit" disabled={!isConfirmed} block> <Checkbox
{msg("recovery-codes-action-complete")} id="kcRecoveryCodesConfirmationCheck"
</Button> name="kcRecoveryCodesConfirmationCheck"
)} onChange={e => setIsConfirmed(e.target.checked)}
</Form> style={{ marginBottom: "0px" }}
>
{msg("recovery-codes-confirmation-message")}
</Checkbox>
</Space>
<Form id="kc-recovery-codes-settings-form" method="post" action={kcContext.url.loginAction}>
<Input
type="hidden"
name="generatedRecoveryAuthnCodes"
value={recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString}
/>
<Input type="hidden" name="generatedAt" value={recoveryAuthnCodesConfigBean.generatedAt} />
<Input type="hidden" id="userLabel" name="userLabel" value={msgStr("recovery-codes-label-default")} />
<LogoutOtherSessions i18n={i18n} />
<div style={{ marginTop: 24 }}>
{isAppInitiatedAction ? (
<Space>
<Button type="primary" size="large" id="saveRecoveryAuthnCodesBtn" htmlType="submit" disabled={!isConfirmed}>
{msg("recovery-codes-action-complete")}
</Button>
<Button size="large" id="cancelRecoveryAuthnCodesBtn" name="cancel-aia" value="true" htmlType="submit">
{msg("recovery-codes-action-cancel")}
</Button>
</Space>
) : (
<Button type="primary" size="large" id="saveRecoveryAuthnCodesBtn" htmlType="submit" disabled={!isConfirmed} block>
{msg("recovery-codes-action-complete")}
</Button>
)}
</div>
</Form>
</div>
</Template> </Template>
); );
} }

View File

@ -1,11 +1,9 @@
import { Form, Input, Button, Typography, Divider } from "antd"; import { Form, Input, Button } from "antd";
import { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const { Title } = Typography;
export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcContext, { pageId: "login-recovery-authn-code-input.ftl" }>, I18n>) { export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcContext, { pageId: "login-recovery-authn-code-input.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { url, messagesPerField, recoveryAuthnCodesInputBean } = kcContext; const { url, messagesPerField, recoveryAuthnCodesInputBean } = kcContext;
@ -20,10 +18,17 @@ export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcC
i18n={i18n} i18n={i18n}
doUseDefaultCss={doUseDefaultCss} doUseDefaultCss={doUseDefaultCss}
classes={classes} classes={classes}
headerNode={<Title level={3}>{msg("auth-recovery-code-header")}</Title>} headerNode={msg("auth-recovery-code-header")}
displayMessage={!hasError} displayMessage={!hasError}
> >
<Form id="kc-recovery-code-login-form" layout="vertical" method="post" action={url.loginAction} size="large"> <Form
id="kc-recovery-code-login-form"
layout="vertical"
method="post"
action={url.loginAction}
size="large"
style={{ padding: "0 15px" }}
>
<Form.Item <Form.Item
label={msg("auth-recovery-code-prompt", `${recoveryAuthnCodesInputBean.codeNumber}`)} label={msg("auth-recovery-code-prompt", `${recoveryAuthnCodesInputBean.codeNumber}`)}
validateStatus={hasError ? "error" : ""} validateStatus={hasError ? "error" : ""}
@ -41,9 +46,9 @@ export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcC
> >
<Input id="recoveryCodeInput" name="recoveryCodeInput" autoComplete="off" autoFocus aria-invalid={hasError} tabIndex={1} /> <Input id="recoveryCodeInput" name="recoveryCodeInput" autoComplete="off" autoFocus aria-invalid={hasError} tabIndex={1} />
</Form.Item> </Form.Item>
<Divider />
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" block size="large" id="kc-login" name="login"> <Button type="primary" htmlType="submit" block size="large" id="kc-login" name="login" style={{ marginTop: 12 }}>
{msgStr("doLogIn")} {msgStr("doLogIn")}
</Button> </Button>
</Form.Item> </Form.Item>

View File

@ -3,7 +3,7 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const { Text, Title } = Typography; const { Text } = Typography;
export default function LoginResetOtp(props: PageProps<Extract<KcContext, { pageId: "login-reset-otp.ftl" }>, I18n>) { export default function LoginResetOtp(props: PageProps<Extract<KcContext, { pageId: "login-reset-otp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -17,9 +17,9 @@ export default function LoginResetOtp(props: PageProps<Extract<KcContext, { page
doUseDefaultCss={doUseDefaultCss} doUseDefaultCss={doUseDefaultCss}
classes={classes} classes={classes}
displayMessage={!messagesPerField.existsError("totp")} displayMessage={!messagesPerField.existsError("totp")}
headerNode={<Title level={3}>{msg("doLogIn")}</Title>} headerNode={msg("doLogIn")}
> >
<Form id="kc-otp-reset-form" layout="vertical" method="post" action={url.loginAction}> <Form id="kc-otp-reset-form" layout="vertical" method="post" action={url.loginAction} style={{ padding: "0 15px" }}>
<Space direction="vertical" style={{ width: "100%" }}> <Space direction="vertical" style={{ width: "100%" }}>
<Text id="kc-otp-reset-form-description">{msg("otp-reset-description")}</Text> <Text id="kc-otp-reset-form-description">{msg("otp-reset-description")}</Text>
@ -32,12 +32,10 @@ export default function LoginResetOtp(props: PageProps<Extract<KcContext, { page
id={`kc-otp-credential-${index}`} id={`kc-otp-credential-${index}`}
style={{ style={{
display: "block", display: "block",
height: "40px",
lineHeight: "40px",
margin: "8px 0" margin: "8px 0"
}} }}
> >
<Space style={{ marginBottom: "3px" }}> <Space style={{ paddingBottom: "10px" }}>
<img <img
src={"https://cdn.tombutcher.work/icons/auth/c-key.svg"} src={"https://cdn.tombutcher.work/icons/auth/c-key.svg"}
width={14} width={14}

View File

@ -1,10 +1,12 @@
import React from "react"; import React from "react";
import { Form, Input, Button, Alert, Space, Divider } from "antd"; import { Form, Input, Button, Alert, Space, Typography } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
import { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
const { Link } = Typography;
// Define form values interface // Define form values interface
interface ResetPasswordFormValues { interface ResetPasswordFormValues {
username: string; username: string;
@ -22,9 +24,9 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
// Determine which label to use based on realm settings // Determine which label to use based on realm settings
const inputLabel = !realm.loginWithEmailAllowed ? msg("username") : !realm.registrationEmailAsUsername ? msg("usernameOrEmail") : msg("email"); const inputLabel = !realm.loginWithEmailAllowed ? msg("username") : !realm.registrationEmailAsUsername ? msg("usernameOrEmail") : msg("email");
const inputPrefix = !realm.loginWithEmailAllowed ? ( const inputPrefix = !realm.loginWithEmailAllowed ? (
<img src={"/c-person.svg"} width={14} style={{ marginRight: "3px" }} /> <img src={"https://cdn.tombutcher.work/icons/auth/c-person.svg"} width={14} style={{ marginRight: "3px" }} />
) : ( ) : (
<img src={"/c-at.svg"} width={14} /> <img src={"https://cdn.tombutcher.work/icons/auth/c-at.svg"} width={14} />
); );
const handleSubmit = (): void => { const handleSubmit = (): void => {
@ -41,11 +43,10 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
kcContext={kcContext} kcContext={kcContext}
i18n={i18n} i18n={i18n}
doUseDefaultCss={false} doUseDefaultCss={false}
infoNode={false}
displayMessage={!messagesPerField.existsError("username")} displayMessage={!messagesPerField.existsError("username")}
headerNode={msg("emailForgotTitle")} headerNode={msg("emailForgotTitle")}
> >
{displayInfo && displayMessage && <Alert message={infoMessage} type="info" showIcon style={{ marginBottom: 24 }} />}
<Form<ResetPasswordFormValues> <Form<ResetPasswordFormValues>
id="kc-reset-password-form" id="kc-reset-password-form"
layout="vertical" layout="vertical"
@ -54,7 +55,10 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
onFinish={handleSubmit} onFinish={handleSubmit}
action={url.loginAction} action={url.loginAction}
method="post" method="post"
style={{ padding: "0 15px" }}
> >
{displayInfo && displayMessage && <Alert message={infoMessage} type="info" showIcon style={{ marginBottom: 24 }} />}
<Form.Item <Form.Item
label={inputLabel} label={inputLabel}
name="username" name="username"
@ -79,17 +83,15 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
aria-invalid={messagesPerField.existsError("username") ? "true" : "false"} aria-invalid={messagesPerField.existsError("username") ? "true" : "false"}
/> />
</Form.Item> </Form.Item>
<Divider />
<Form.Item style={{ marginBottom: 12 }}> <Form.Item style={{ marginBottom: 22, marginTop: 26 }}>
<Button type="primary" htmlType="submit" size="large" block> <Button type="primary" htmlType="submit" size="large" block>
{msgStr("doSubmit")} {msgStr("doSubmit")}
</Button> </Button>
</Form.Item> </Form.Item>
<Space style={{ width: "100%", justifyContent: "center" }}> <Space style={{ width: "100%", justifyContent: "center" }}>
<Button type="link" href={url.loginUrl}> <Link href={url.loginUrl}>{msg("backToLogin")}</Link>
{msg("backToLogin")}
</Button>
</Space> </Space>
</Form> </Form>
</Template> </Template>

View File

@ -1,12 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Typography, Form, Input, Checkbox, Divider, Flex } from "antd"; import { Button, Form, Input, Checkbox, Flex } from "antd";
import { EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons"; import { EyeOutlined, EyeInvisibleOutlined } from "@ant-design/icons";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const { Title } = Typography;
type FieldType = { type FieldType = {
"password-new": string; "password-new": string;
"password-confirm": string; "password-confirm": string;
@ -56,9 +54,9 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
doUseDefaultCss={false} doUseDefaultCss={false}
classes={props.classes} classes={props.classes}
displayMessage={!messagesPerField.existsError("password", "password-confirm")} displayMessage={!messagesPerField.existsError("password", "password-confirm")}
headerNode={<Title level={2}>{msg("updatePasswordTitle")}</Title>} headerNode={msg("updatePasswordTitle")}
> >
<Form id="kc-passwd-update-form" layout="vertical" onFinish={handleSubmit}> <Form id="kc-passwd-update-form" layout="vertical" onFinish={handleSubmit} style={{ padding: "0 15px" }}>
<Form.Item <Form.Item
label={msg("passwordNew")} label={msg("passwordNew")}
name="password-new" name="password-new"
@ -96,8 +94,6 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
<Checkbox>{msg("logoutOtherSessions")}</Checkbox> <Checkbox>{msg("logoutOtherSessions")}</Checkbox>
</Form.Item> </Form.Item>
<Divider />
<Form.Item> <Form.Item>
<Flex gap="middle"> <Flex gap="middle">
<Button <Button
@ -105,6 +101,7 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
size="large" size="large"
htmlType="submit" htmlType="submit"
iconPosition="end" iconPosition="end"
style={{ marginTop: 6, flexGrow: 2 }}
icon={ icon={
<img <img
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"} src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
@ -114,7 +111,6 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
} }
loading={isSubmitLoading} loading={isSubmitLoading}
disabled={isCancelLoading || isSubmitLoading} disabled={isCancelLoading || isSubmitLoading}
style={{ flexGrow: 2 }}
> >
{msgStr("doSubmit")} {msgStr("doSubmit")}
</Button> </Button>

View File

@ -64,22 +64,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
kcContext={kcContext} kcContext={kcContext}
i18n={i18n} i18n={i18n}
doUseDefaultCss={false} doUseDefaultCss={false}
headerNode={ headerNode={msg("loginAccountTitle")}
<>
{client.attributes.logoUri ? (
<Space align="start" direction="vertical">
<img
src={client.attributes.logoUri}
alt={client.name || client.clientId}
style={{ maxHeight: "64px", maxWidth: "100%", marginBottom: "20px" }}
/>
<Title level={3}>{msg("loginAccountTitle")}</Title>
</Space>
) : (
<Title level={3}>{msg("loginAccountTitle")}</Title>
)}
</>
}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled} displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
infoNode={ infoNode={
<Space> <Space>
@ -124,6 +109,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
layout="vertical" layout="vertical"
requiredMark={false} requiredMark={false}
onFinish={onFinish} onFinish={onFinish}
style={{ margin: "0 15px" }}
> >
{!usernameHidden && ( {!usernameHidden && (
<Form.Item <Form.Item
@ -140,7 +126,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
</Form.Item> </Form.Item>
)} )}
<Row justify="space-between" align="middle"> <Row justify="space-between" align="middle" style={{ paddingTop: "2px" }}>
{realm.rememberMe && !usernameHidden && ( {realm.rememberMe && !usernameHidden && (
<Col> <Col>
<Form.Item name="rememberMe" valuePropName="checked" noStyle> <Form.Item name="rememberMe" valuePropName="checked" noStyle>
@ -152,8 +138,6 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
)} )}
</Row> </Row>
<Divider />
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"
@ -165,6 +149,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
disabled={isLoading} disabled={isLoading}
loading={isLoading} loading={isLoading}
iconPosition="end" iconPosition="end"
style={{ marginTop: 24 }}
icon={ icon={
<img <img
src={"https://cdn.tombutcher.work/icons/auth/w-right.svg"} src={"https://cdn.tombutcher.work/icons/auth/w-right.svg"}

View File

@ -11,8 +11,8 @@ export default function LoginVerifyEmail(props: PageProps<Extract<KcContext, { p
const { url, user } = kcContext; const { url, user } = kcContext;
return ( return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} classes={{}} displayInfo headerNode={msg("emailVerifyTitle")}> <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} classes={{}} displayInfo={false} headerNode={msg("emailVerifyTitle")}>
<Space direction="vertical" size="large" style={{ width: "100%" }}> <Space direction="vertical" size="large" style={{ width: "100%", padding: "0 15px"}}>
<Text>{msg("emailVerifyInstruction1", user?.email ?? "")}</Text> <Text>{msg("emailVerifyInstruction1", user?.email ?? "")}</Text>
<Text> <Text>

View File

@ -1,4 +1,4 @@
import { Button, Typography, Divider } from "antd"; import { Button, Typography } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
@ -40,16 +40,11 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
}; };
return ( return (
<Template <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} classes={props.classes} headerNode={msg("logoutConfirmTitle")}>
kcContext={kcContext} <div id="kc-logout-confirm" style={{ margin: "0 15px" }}>
i18n={i18n} <div style={{ marginBottom: "25px" }}>
doUseDefaultCss={false} <Text className="instruction">{msg("logoutConfirmHeader")}</Text>
classes={props.classes} </div>
headerNode={<Title level={2}>{msg("logoutConfirmTitle")}</Title>}
>
<div id="kc-logout-confirm">
<Text className="instruction">{msg("logoutConfirmHeader")}</Text>
<Divider />
<Button <Button
type="primary" type="primary"
size="large" size="large"

View File

@ -7,6 +7,7 @@ import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFo
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
import { useMediaQuery } from "react-responsive";
const { Title, Text, Link } = Typography; const { Title, Text, Link } = Typography;
@ -19,6 +20,7 @@ export default function Register(props: RegisterProps) {
const { kcContext, i18n, UserProfileFormFields, doMakeUserConfirmPassword } = props; const { kcContext, i18n, UserProfileFormFields, doMakeUserConfirmPassword } = props;
const { url, messagesPerField, recaptchaRequired, recaptchaVisible, recaptchaSiteKey, recaptchaAction, termsAcceptanceRequired } = kcContext; const { url, messagesPerField, recaptchaRequired, recaptchaVisible, recaptchaSiteKey, recaptchaAction, termsAcceptanceRequired } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const isMobile = useMediaQuery({ maxWidth: 600 });
const [areTermsAccepted, setAreTermsAccepted] = useState(false); const [areTermsAccepted, setAreTermsAccepted] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
@ -64,67 +66,69 @@ export default function Register(props: RegisterProps) {
return ( return (
<Form form={form} layout="vertical" name="registerForm" requiredMark onFinish={onFinish}> <Form form={form} layout="vertical" name="registerForm" requiredMark onFinish={onFinish}>
<UserProfileFormFields <div style={{ overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px" }}>
layout="vertical" <UserProfileFormFields
kcContext={kcContext} layout="vertical"
i18n={i18n} kcContext={kcContext}
kcClsx={options => (typeof options === "object" && options !== null ? (options as { classKey?: string }).classKey ?? "" : "")}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
onIsFormSubmittableValueChange={() => {}}
/>
{termsAcceptanceRequired && (
<TermsAcceptance
i18n={i18n} i18n={i18n}
messagesPerField={messagesPerField} kcClsx={options => (typeof options === "object" && options !== null ? (options as { classKey?: string }).classKey ?? "" : "")}
areTermsAccepted={areTermsAccepted} doMakeUserConfirmPassword={doMakeUserConfirmPassword}
onAreTermsAcceptedValueChange={setAreTermsAccepted} onIsFormSubmittableValueChange={() => {}}
/> />
)} </div>
<div style={{ padding: "0 15px" }}>
{termsAcceptanceRequired && (
<TermsAcceptance
i18n={i18n}
messagesPerField={messagesPerField}
areTermsAccepted={areTermsAccepted}
onAreTermsAcceptedValueChange={setAreTermsAccepted}
/>
)}
{recaptchaRequired && (recaptchaVisible || recaptchaAction === undefined) && ( {recaptchaRequired && (recaptchaVisible || recaptchaAction === undefined) && (
<Form.Item> <Form.Item>
<div className="g-recaptcha" data-size="compact" data-sitekey={recaptchaSiteKey} data-action={recaptchaAction} /> <div className="g-recaptcha" data-size="compact" data-sitekey={recaptchaSiteKey} data-action={recaptchaAction} />
</Form.Item>
)}
<Form.Item style={{ marginTop: 24 }}>
<Space direction="vertical" style={{ width: "100%" }}>
{recaptchaRequired && !recaptchaVisible && recaptchaAction !== undefined ? (
<Button
type="primary"
htmlType="submit"
block
size="large"
disabled={isLoading}
loading={isLoading}
className="g-recaptcha"
data-sitekey={recaptchaSiteKey}
data-callback={() => {
form.submit();
}}
data-action={recaptchaAction}
>
{msg("doRegister")}
</Button>
) : (
<Button
type="primary"
htmlType="submit"
block
size="large"
loading={isLoading}
disabled={!submittable || isLoading || (termsAcceptanceRequired && !areTermsAccepted)}
>
{msgStr("doRegister")}
</Button>
)}
<div style={{ marginTop: 16, textAlign: "center" }}>
<Link href={url.loginUrl}>{msg("backToLogin")}</Link>
</div>
</Space>
</Form.Item> </Form.Item>
)} </div>
<Divider />
<Form.Item>
<Space direction="vertical" style={{ width: "100%" }}>
{recaptchaRequired && !recaptchaVisible && recaptchaAction !== undefined ? (
<Button
type="primary"
htmlType="submit"
block
size="large"
disabled={isLoading}
loading={isLoading}
className="g-recaptcha"
data-sitekey={recaptchaSiteKey}
data-callback={() => {
form.submit();
}}
data-action={recaptchaAction}
>
{msg("doRegister")}
</Button>
) : (
<Button
type="primary"
htmlType="submit"
block
size="large"
loading={isLoading}
disabled={!submittable || isLoading || (termsAcceptanceRequired && !areTermsAccepted)}
>
{msgStr("doRegister")}
</Button>
)}
<div style={{ marginTop: 16, textAlign: "center" }}>
<Link href={url.loginUrl}>{msg("backToLogin")}</Link>
</div>
</Space>
</Form.Item>
</Form> </Form>
); );
} }

View File

@ -38,7 +38,7 @@ export default function SamlPostForm(props: PageProps<Extract<KcContext, { pageI
return ( return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("saml.post-form.title")}> <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("saml.post-form.title")}>
<Flex align="middle" justify="start" gap={"middle"}> <Flex align="middle" justify="start" gap={"middle"} style={{ padding: '0 15px'}}>
<Spin indicator={antIcon} /> <Spin indicator={antIcon} />
<Text style={{ margin: 0 }}>{msg("saml.post-form.message")}</Text> <Text style={{ margin: 0 }}>{msg("saml.post-form.message")}</Text>

View File

@ -34,7 +34,7 @@ export default function SelectAuthenticator(props: PageProps<Extract<KcContext,
</> </>
} }
> >
<form id="kc-select-credential-form" action={url.loginAction} method="post"> <form id="kc-select-credential-form" action={url.loginAction} method="post" style={{ padding: '0 15px'}}>
<List <List
dataSource={auth.authenticationSelections} dataSource={auth.authenticationSelections}
itemLayout="horizontal" itemLayout="horizontal"

View File

@ -1,4 +1,4 @@
import { Button, Divider, Typography, Card, Flex } from "antd"; import { Button, Typography, Card, Flex } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
@ -38,14 +38,14 @@ export default function Terms(props: PageProps<Extract<KcContext, { pageId: "ter
doUseDefaultCss={false} doUseDefaultCss={false}
classes={props.classes} classes={props.classes}
displayMessage={false} displayMessage={false}
headerNode={<Title level={2}>{msg("termsTitle")}</Title>} headerNode={msg("termsTitle")}
> >
<div className="terms-container"> <div className="terms-container" style={{ padding: "0 15px" }}>
{/* Scrollable terms box */} {/* Scrollable terms box */}
<Card <Card
style={{ style={{
marginBottom: 24, marginBottom: 26,
maxHeight: "300px", maxHeight: "250px",
overflow: "auto" overflow: "auto"
}} }}
bordered={true} bordered={true}
@ -54,7 +54,6 @@ export default function Terms(props: PageProps<Extract<KcContext, { pageId: "ter
<Paragraph>{msg("termsText")}</Paragraph> <Paragraph>{msg("termsText")}</Paragraph>
</div> </div>
</Card> </Card>
<Divider />
<Flex gap="middle"> <Flex gap="middle">
<Button <Button
type="primary" type="primary"

View File

@ -1,7 +1,7 @@
import type { JSX } from "keycloakify/tools/JSX"; import type { JSX } from "keycloakify/tools/JSX";
import { useState } from "react"; import { useState } from "react";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot"; import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import { Button, Checkbox, Flex, Divider } from "antd"; import { Button, Checkbox, Flex } from "antd";
import { getKcClsx } from "keycloakify/login/lib/kcClsx"; import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps"; import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
@ -37,8 +37,8 @@ export default function UpdateEmail(props: UpdateEmailProps) {
displayMessage={messagesPerField.exists("global")} displayMessage={messagesPerField.exists("global")}
headerNode={msg("updateEmailTitle")} headerNode={msg("updateEmailTitle")}
> >
<form id="kc-update-email-form" action={url.loginAction} method="post"> <form id="kc-update-email-form" action={url.loginAction} method="post" style={{ padding: '0 15px'}}>
<div style={{ marginBottom: 24 }} className="kctbform"> <div style={{ marginBottom: 15 }} className="kctbform">
{/* Keep original UserProfileFormFields component, but wrap in Ant Design styling */} {/* Keep original UserProfileFormFields component, but wrap in Ant Design styling */}
<UserProfileFormFields <UserProfileFormFields
kcContext={kcContext} kcContext={kcContext}
@ -54,7 +54,6 @@ export default function UpdateEmail(props: UpdateEmailProps) {
{msg("logoutOtherSessions")} {msg("logoutOtherSessions")}
</Checkbox> </Checkbox>
</div> </div>
<Divider />
<Flex gap={"middle"}> <Flex gap={"middle"}>
<Button <Button
style={{ flexGrow: 2 }} style={{ flexGrow: 2 }}

View File

@ -1,5 +1,5 @@
import React, { Fragment } from "react"; import React, { Fragment } from "react";
import { Button, Form, Input, List, Typography, Space, Divider } from "antd"; import { Button, Form, Input, List, Typography, Space } from "antd";
import { DesktopOutlined, MobileOutlined } from "@ant-design/icons"; import { DesktopOutlined, MobileOutlined } from "@ant-design/icons";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
@ -24,7 +24,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const { url, realm, registrationDisabled, authenticators, shouldDisplayAuthenticators, client } = kcContext; const { url, realm, registrationDisabled, authenticators, shouldDisplayAuthenticators, client, message } = kcContext;
const { msg, msgStr, advancedMsg } = i18n; const { msg, msgStr, advancedMsg } = i18n;
@ -37,7 +37,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
}); });
React.useEffect(() => { React.useEffect(() => {
if (isLoading) return; if (isLoading || message != undefined) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
const btn = document.getElementById(authButtonId) as HTMLButtonElement | null; const btn = document.getElementById(authButtonId) as HTMLButtonElement | null;
if (btn && !btn.disabled) { if (btn && !btn.disabled) {
@ -66,112 +66,98 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
<Link href={url.registrationUrl}>{msg("doRegister")}</Link> <Link href={url.registrationUrl}>{msg("doRegister")}</Link>
</Space> </Space>
} }
headerNode={ headerNode={msg("webauthn-login-title")}
<>
{client.attributes.logoUri ? (
<Space align="start" direction="vertical">
<img
src={client.attributes.logoUri}
alt={client.name || client.clientId}
style={{ maxHeight: "64px", maxWidth: "100%", marginBottom: "20px" }}
/>
<Title level={3}>{msg("webauthn-login-title")}</Title>
</Space>
) : (
<Title level={3}>{msg("webauthn-login-title")}</Title>
)}
</>
}
> >
<Form id="webauth" action={url.loginAction} method="post" layout="vertical"> <div style={{ padding: "0 15px" }}>
<Input type="hidden" id="clientDataJSON" name="clientDataJSON" /> <Form id="webauth" action={url.loginAction} method="post" layout="vertical">
<Input type="hidden" id="authenticatorData" name="authenticatorData" /> <Input type="hidden" id="clientDataJSON" name="clientDataJSON" />
<Input type="hidden" id="signature" name="signature" /> <Input type="hidden" id="authenticatorData" name="authenticatorData" />
<Input type="hidden" id="credentialId" name="credentialId" /> <Input type="hidden" id="signature" name="signature" />
<Input type="hidden" id="userHandle" name="userHandle" /> <Input type="hidden" id="credentialId" name="credentialId" />
<Input type="hidden" id="error" name="error" /> <Input type="hidden" id="userHandle" name="userHandle" />
</Form> <Input type="hidden" id="error" name="error" />
</Form>
{authenticators && ( {authenticators && (
<> <>
<Form id="authn_select"> <Form id="authn_select">
{authenticators.authenticators.map((authenticator: Authenticator, index: number) => ( {authenticators.authenticators.map((authenticator: Authenticator, index: number) => (
<Input key={index} type="hidden" name="authn_use_chk" value={authenticator.credentialId} /> <Input key={index} type="hidden" name="authn_use_chk" value={authenticator.credentialId} />
))} ))}
</Form> </Form>
{shouldDisplayAuthenticators && ( {shouldDisplayAuthenticators && (
<> <>
<List <List
bordered style={{ marginBottom: "30px" }}
dataSource={authenticators.authenticators as Authenticator[]} bordered
renderItem={(authenticator: Authenticator, i: number) => ( dataSource={authenticators.authenticators as Authenticator[]}
<List.Item key={i} id={`kc-webauthn-authenticator-item-${i}`}> renderItem={(authenticator: Authenticator, i: number) => (
<List.Item.Meta <List.Item key={i} id={`kc-webauthn-authenticator-item-${i}`}>
avatar={getAuthenticatorIcon(authenticator.transports.iconClass as string)} <List.Item.Meta
title={ avatar={getAuthenticatorIcon(authenticator.transports.iconClass as string)}
<Text strong id={`kc-webauthn-authenticator-label-${i}`} style={{ marginBottom: 0 }}> title={
{advancedMsg(authenticator.label)} <Text strong id={`kc-webauthn-authenticator-label-${i}`} style={{ marginBottom: 0 }}>
</Text> {advancedMsg(authenticator.label)}
}
description={
<Space direction="vertical" size={1}>
{authenticator.transports.displayNameProperties &&
authenticator.transports.displayNameProperties.length > 0 && (
<Text id={`kc-webauthn-authenticator-transport-${i}`}>
{authenticator.transports.displayNameProperties
.map((displayNameProperty: string, j: number, arr: string[]) => ({
displayNameProperty,
hasNext: j !== arr.length - 1
}))
.map(({ displayNameProperty, hasNext }) => (
<Fragment key={displayNameProperty}>
{advancedMsg(displayNameProperty)}
{hasNext && <span>, </span>}
</Fragment>
))}
</Text>
)}
<Text type="secondary">
<span id={`kc-webauthn-authenticator-createdlabel-${i}`}>
{msg("webauthn-createdAt-label")}
</span>
<span id={`kc-webauthn-authenticator-created-${i}`}> {authenticator.createdAt}</span>
</Text> </Text>
</Space> }
} description={
/> <Space direction="vertical" size={1}>
</List.Item> {authenticator.transports.displayNameProperties &&
)} authenticator.transports.displayNameProperties.length > 0 && (
style={{ marginBottom: 0 }} <Text id={`kc-webauthn-authenticator-transport-${i}`}>
/> {authenticator.transports.displayNameProperties
<Divider /> .map((displayNameProperty: string, j: number, arr: string[]) => ({
</> displayNameProperty,
)} hasNext: j !== arr.length - 1
</> }))
)} .map(({ displayNameProperty, hasNext }) => (
<Fragment key={displayNameProperty}>
{advancedMsg(displayNameProperty)}
{hasNext && <span>, </span>}
</Fragment>
))}
</Text>
)}
<Text type="secondary">
<span id={`kc-webauthn-authenticator-createdlabel-${i}`}>
{msg("webauthn-createdAt-label")}
</span>
<span id={`kc-webauthn-authenticator-created-${i}`}> {authenticator.createdAt}</span>
</Text>
</Space>
}
/>
</List.Item>
)}
/>
</>
)}
</>
)}
<Form.Item style={{ marginBottom: 0 }}> <Form.Item style={{ marginBottom: 0 }}>
<Button <Button
id={authButtonId} id={authButtonId}
type="primary" type="primary"
size="large" size="large"
block block
autoFocus autoFocus
onClick={() => setIsLoading(true)} onClick={() => setIsLoading(true)}
disabled={isLoading} disabled={isLoading}
loading={isLoading} loading={isLoading}
icon={ icon={
<img <img
src={"https://cdn.tombutcher.work/icons/auth/w-passkey.svg"} src={"https://cdn.tombutcher.work/icons/auth/w-passkey.svg"}
width={24} width={24}
style={{ marginTop: "3px", marginBottom: "0px" }} style={{ marginTop: "3px", marginBottom: "0px" }}
/> />
} }
> >
<span>{msgStr("webauthn-doAuthenticate")}</span> <span>{msgStr("webauthn-doAuthenticate")}</span>
</Button> </Button>
</Form.Item> </Form.Item>
</div>
</Template> </Template>
); );
} }

View File

@ -64,7 +64,7 @@ export default function WebauthnError(props: PageProps<Extract<KcContext, { page
displayMessage displayMessage
headerNode={msg("webauthn-error-title")} headerNode={msg("webauthn-error-title")}
> >
<Form onFinish={handleSubmit}> <Form onFinish={handleSubmit} style={{ padding: '0 15px'}}>
<Form.Item> <Form.Item>
<Flex gap={"middle"}> <Flex gap={"middle"}>
<Button <Button

View File

@ -1,12 +1,10 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Form, Checkbox, Typography, Divider, Flex } from "antd"; import { Button, Form, Checkbox, Flex } from "antd";
import { useScript } from "keycloakify/login/pages/WebauthnRegister.useScript"; import { useScript } from "keycloakify/login/pages/WebauthnRegister.useScript";
import type { PageProps } from "keycloakify/login/pages/PageProps"; import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext"; import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n"; import type { I18n } from "../i18n";
const { Title } = Typography;
interface LogoutOtherSessionsProps { interface LogoutOtherSessionsProps {
i18n: I18n; i18n: I18n;
} }
@ -17,7 +15,7 @@ function LogoutOtherSessions(props: LogoutOtherSessionsProps): React.ReactElemen
const { msg } = i18n; const { msg } = i18n;
return ( return (
<Form.Item name="logout-sessions" valuePropName="checked" initialValue={true}> <Form.Item name="logout-sessions" valuePropName="checked" initialValue={true} style={{ paddingBottom: "24px" }}>
<Checkbox id="logout-sessions" name="logout-sessions" value="on"> <Checkbox id="logout-sessions" name="logout-sessions" value="on">
{msg("logoutOtherSessions")} {msg("logoutOtherSessions")}
</Checkbox> </Checkbox>
@ -67,56 +65,57 @@ export default function WebauthnRegister(props: PageProps<Extract<KcContext, { p
}; };
return ( return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} headerNode={<Title level={2}>{msg("webauthn-login-title")}</Title>}> <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} headerNode={msg("webauthn-login-title")}>
<Form id="register" layout="vertical" action={url.loginAction} method="post"> <div style={{ margin: "0 15px" }}>
{/* Hidden fields for WebAuthn registration data */} <Form id="register" layout="vertical" action={url.loginAction} method="post">
<input type="hidden" id="clientDataJSON" name="clientDataJSON" /> {/* Hidden fields for WebAuthn registration data */}
<input type="hidden" id="attestationObject" name="attestationObject" /> <input type="hidden" id="clientDataJSON" name="clientDataJSON" />
<input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId" /> <input type="hidden" id="attestationObject" name="attestationObject" />
<input type="hidden" id="authenticatorLabel" name="authenticatorLabel" /> <input type="hidden" id="publicKeyCredentialId" name="publicKeyCredentialId" />
<input type="hidden" id="transports" name="transports" /> <input type="hidden" id="authenticatorLabel" name="authenticatorLabel" />
<input type="hidden" id="error" name="error" /> <input type="hidden" id="transports" name="transports" />
<input type="hidden" id="error" name="error" />
<LogoutOtherSessions i18n={i18n} /> <LogoutOtherSessions i18n={i18n} />
</Form> </Form>
<Divider /> <Form.Item>
<Form.Item> <Flex gap={"middle"}>
<Flex gap={"middle"}>
<Button
type="primary"
size="large"
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-passkey.svg"}
style={{ marginTop: "4px", marginBottom: 0 }}
width={24}
/>
}
style={{ flexGrow: 1 }}
id={authButtonId}
onClick={() => setIsSubmitLoading(true)}
htmlType="submit"
loading={isSubmitLoading}
disabled={isSubmitLoading || isCancelLoading}
>
{msgStr("doRegisterSecurityKey")}
</Button>
{!isSetRetry && isAppInitiatedAction && (
<Button <Button
type="default" type="primary"
size="large" size="large"
id="cancelWebAuthnAIA" icon={
onClick={handleCancel} <img
src={"https://cdn.tombutcher.work/icons/auth/w-passkey.svg"}
style={{ marginTop: "4px", marginBottom: 0 }}
width={24}
/>
}
style={{ flexGrow: 1 }} style={{ flexGrow: 1 }}
loading={isCancelLoading} id={authButtonId}
onClick={() => setIsSubmitLoading(true)}
htmlType="submit"
loading={isSubmitLoading}
disabled={isSubmitLoading || isCancelLoading} disabled={isSubmitLoading || isCancelLoading}
> >
{msg("doCancel")} {msgStr("doRegisterSecurityKey")}
</Button> </Button>
)}
</Flex> {!isSetRetry && isAppInitiatedAction && (
</Form.Item> <Button
type="default"
size="large"
id="cancelWebAuthnAIA"
onClick={handleCancel}
style={{ flexGrow: 1 }}
loading={isCancelLoading}
disabled={isSubmitLoading || isCancelLoading}
>
{msg("doCancel")}
</Button>
)}
</Flex>
</Form.Item>
</div>
</Template> </Template>
); );
} }