Improved dark ui, fixed sizing and mobile support.
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-14 00:55:32 +01:00
parent 119eaa09d1
commit b0ace8b6a0
36 changed files with 855 additions and 688 deletions

View File

@ -86,28 +86,13 @@ export default function KcPage(props: { kcContext: KcContext }) {
); );
case "register.ftl": case "register.ftl":
return ( return (
<Template <Register
kcContext={kcContext} {...{ kcContext, i18n, classes }}
i18n={i18n} Template={Template}
doUseDefaultCss={false} doUseDefaultCss={false}
classes={{}} UserProfileFormFields={UserProfileFormFields}
headerNode={ doMakeUserConfirmPassword={doMakeUserConfirmPassword}
kcContext.messageHeader !== undefined />
? i18n.advancedMsg(kcContext.messageHeader)
: i18n.msg("registerTitle")
}
displayMessage={kcContext.messagesPerField.exists(
"global"
)}
>
<Register
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={false}
UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
</Template>
); );
case "error.ftl": case "error.ftl":
@ -184,23 +169,13 @@ export default function KcPage(props: { kcContext: KcContext }) {
); );
case "login-update-profile.ftl": case "login-update-profile.ftl":
return ( return (
<Template <LoginUpdateProfile
kcContext={kcContext} {...{ kcContext, i18n, classes }}
i18n={i18n} Template={Template}
doUseDefaultCss={false} doUseDefaultCss={true}
headerNode={i18n.msg("loginProfileTitle")} UserProfileFormFields={UserProfileFormFields}
displayMessage={kcContext.messagesPerField.exists( doMakeUserConfirmPassword={doMakeUserConfirmPassword}
"global" />
)}
>
<LoginUpdateProfile
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
</Template>
); );
case "login-oauth2-device-verify-user-code.ftl": case "login-oauth2-device-verify-user-code.ftl":
return ( return (

View File

@ -6,7 +6,11 @@ import { type Container, type ISourceOptions } from "@tsparticles/engine";
import { loadSlim } from "@tsparticles/slim"; // if you are going to use `loadSlim`, install the "@tsparticles/slim" package too. import { loadSlim } from "@tsparticles/slim"; // if you are going to use `loadSlim`, install the "@tsparticles/slim" package too.
// import { loadBasic } from "@tsparticles/basic"; // if you are going to use `loadBasic`, install the "@tsparticles/basic" package too. // import { loadBasic } from "@tsparticles/basic"; // if you are going to use `loadBasic`, install the "@tsparticles/basic" package too.
const ParticlesBackground = () => { type ParticlesBackgroundProps = {
darkMode?: boolean;
};
const ParticlesBackground = ({ darkMode = false }: ParticlesBackgroundProps) => {
const [init, setInit] = useState(false); const [init, setInit] = useState(false);
// this should be run only once per application lifetime // this should be run only once per application lifetime
@ -28,7 +32,85 @@ const ParticlesBackground = () => {
console.log(container); console.log(container);
}; };
const options: ISourceOptions = useMemo( const darkOptions: ISourceOptions = useMemo(
() => ({
background: {
color: {
value: "#FF00A1"
}
},
fpsLimit: 120,
interactivity: {
events: {
onClick: {
enable: false,
mode: "push"
},
onHover: {
enable: true,
mode: "repulse"
}
},
modes: {
push: {
quantity: 4
},
repulse: {
distance: 100,
duration: 5
}
}
},
particles: {
color: {
value: [
"#210014FF",
"#00033BFF",
"#001F24FF",
"#170035FF",
"#000333FF",
"#210014FF"
]
},
links: {
color: "#ffffff",
distance: 150,
enable: true,
opacity: 0.0,
width: 0
},
move: {
direction: "none",
enable: true,
outModes: {
default: "out"
},
random: true,
speed: 1,
straight: false
},
number: {
density: {
enable: true
},
value: 400
},
opacity: {
value: 1
},
shape: {
type: "circle"
},
size: {
value: { min: 100, max: 300 }
}
},
detectRetina: true
}),
[]
);
const lightOptions: ISourceOptions = useMemo(
() => ({ () => ({
background: { background: {
color: { color: {
@ -119,11 +201,11 @@ const ParticlesBackground = () => {
<Particles <Particles
id="tsparticles" id="tsparticles"
particlesLoaded={particlesLoaded} particlesLoaded={particlesLoaded}
options={options} options={darkMode ? darkOptions : lightOptions}
/> />
<div <div
style={{ style={{
background: "rgba(255, 255, 255, 0.0)", background: "rgba(0, 0, 0, 0.0)",
backdropFilter: "blur(50px)", backdropFilter: "blur(50px)",
width: "100%", width: "100%",
height: "100%", height: "100%",

View File

@ -6,10 +6,10 @@ import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useInitialize } from "keycloakify/login/Template.useInitialize"; import { useInitialize } from "keycloakify/login/Template.useInitialize";
import type { I18n } from "./i18n"; import type { I18n } from "./i18n";
import type { KcContext } from "./KcContext"; import type { KcContext } from "./KcContext";
import { Layout, Typography, Dropdown, Button, Alert, Space, Divider, Card, ConfigProvider, Flex, theme, Spin } from "antd"; import { Layout, Typography, Dropdown, Alert, Space, Divider, Card, ConfigProvider, Flex, theme, Spin } from "antd";
import { GlobalOutlined } from "@ant-design/icons"; import { GlobalOutlined } from "@ant-design/icons";
import ParticlesBackground from "./ParticlesBackground"; import ParticlesBackground from "./ParticlesBackground";
import type { ReactNode } from "react";
const { Content } = Layout; const { Content } = Layout;
const { Title, Text, Link } = Typography; const { Title, Text, Link } = Typography;
@ -17,7 +17,6 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const { const {
displayInfo = false, displayInfo = false,
displayMessage = true, displayMessage = true,
displayRequiredFields = false,
headerNode, headerNode,
infoNode = null, infoNode = null,
documentTitle, documentTitle,
@ -98,11 +97,51 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
})) }))
}; };
const clientInfo = (
<Flex gap={"small"} align="center">
{client.attributes.logoUri ? (
<img
src={client.attributes.logoUri}
alt={client.name || client.clientId}
style={{ marginTop: "0px", width: "18px", height: "18px" }}
/>
) : (
<img
src={"https://cdn.tombutcher.work/icons/auth/c-lock.svg"}
alt={client.name || client.clientId}
style={{ marginTop: "0px", width: "18px", height: "18px" }}
/>
)}
<div>
<Text strong>{client.name}</Text>
</div>
</Flex>
);
const showTryAnotherWayLink = (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<input type="hidden" name="tryAnotherWay" value="on" />
<Link
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</Link>
</form>
);
const showMessage = displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction);
const showImutableUsername = auth !== undefined && auth.showUsername && !auth.showResetCredentials;
return ( return (
<ConfigProvider <ConfigProvider
theme={{ theme={{
token: { token: {
colorPrimary: "rgba(212, 0, 255, 1)", colorPrimary: darkMode ? "#D20294FF" : "rgba(212, 0, 255, 1)",
colorLink: "#6E00FF", colorLink: "#6E00FF",
colorLinkHover: "#b175ff", colorLinkHover: "#b175ff",
borderRadius: 20 borderRadius: 20
@ -111,7 +150,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}} }}
> >
<Layout style={{ minHeight: "var(--unit-100vh)", maxHeight: "var(--unit-100vh)" }}> <Layout style={{ minHeight: "var(--unit-100vh)", maxHeight: "var(--unit-100vh)" }}>
<ParticlesBackground /> <ParticlesBackground darkMode={darkMode} />
{loading == true ? ( {loading == true ? (
<div className="loadingOverlay" style={{ backgroundColor: darkMode ? "#000000" : "#ffffff" }}> <div className="loadingOverlay" style={{ backgroundColor: darkMode ? "#000000" : "#ffffff" }}>
@ -126,20 +165,31 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
> >
<Flex vertical align="center" justify="center" style={{ width: "100vw", height: "var(--unit-100vh)" }} gap={"40px"}> <Flex vertical align="center" justify="center" style={{ width: "100vw", height: "var(--unit-100vh)" }} gap={"40px"}>
{!isMobile && ( {!isMobile && (
<img <>
src="https://cdn.tombutcher.work/logos/logo-horizontal.svg" {darkMode ? (
alt="Logo" <img
style={{ height: "60px", padding: "0 30px", zIndex: 1, marginBottom: 5 }} src="https://cdn.tombutcher.work/logos/logo-auth-horizontal.png"
/> alt="Logo"
style={{ height: "60px", padding: "0 30px", zIndex: 1, marginBottom: 10 }}
/>
) : (
<img
src="https://cdn.tombutcher.work/logos/logo-horizontal.svg"
alt="Logo"
style={{ height: "60px", padding: "0 30px", zIndex: 1, marginBottom: 10 }}
/>
)}
</>
)} )}
<Card <Card
style={{ style={{
background: darkMode ? "#00000025" : "#fffffff2",
borderRadius: isMobile ? "0px" : "20px", borderRadius: isMobile ? "0px" : "20px",
width: isMobile ? "100vw" : "450px", width: isMobile ? "100vw" : "450px",
zIndex: 1, zIndex: 1,
height: isMobile ? "100vh" : "unset", height: isMobile ? "100vh" : "unset",
padding: "23px 15px 30px 15px", padding: "26px 15px 30px 15px",
boxShadow: "0px 5px 15px 5px rgb(0 0 0 / 10%)" boxShadow: darkMode ? "0px 5px 30px 5px rgb(200 200 200 / 5%)" : "0px 5px 15px 5px rgb(0 0 0 / 10%)"
}} }}
variant="borderless" variant="borderless"
styles={{ styles={{
@ -149,110 +199,95 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
} }
}} }}
> >
<Flex style={{ height: "100%" }} vertical> <Flex style={{ height: "100%" }} vertical className={darkMode ? "darkMode" : ""}>
<div style={{ height: "100%", margin: "0 15px" }}> {isMobile && (
{isMobile && ( <Flex style={{ margin: "0 15px", paddingBottom: "16px" }} gap={"middle"} vertical>
<img
src="https://cdn.tombutcher.work/logos/logo-auth.png"
alt="Logo"
style={{ width: "60%", margin: "0", marginBottom: "4px" }}
/>{" "}
<Divider style={{ margin: "4px 0" }} />
</Flex>
)}
<Flex style={{ margin: "0 15px", paddingBottom: "18px" }} vertical gap={"middle"}>
{headerNode && (
<> <>
<img <Title level={2} style={{ marginBottom: "0", flexGrow: 1 }} className={darkMode ? "dark" : ""}>
src="https://cdn.tombutcher.work/logos/logo-auth.png" {headerNode}
alt="Logo" </Title>
style={{ width: "70%", margin: "0" }} {isMobile && <Divider style={{ margin: "4px 0" }} />}
/>{" "}
<Divider style={{ margin: "24px 0" }} />
</> </>
)} )}
<div style={{ marginBottom: "10px" }}> {showImutableUsername && (
<Flex gap={"large"} align="center" style={{ paddingBottom: "8px" }}> <>
<Title level={2} style={{ marginBottom: "0", flexGrow: 1 }}> <Card
{headerNode} style={{ display: "flex", alignItems: "center", margin: 0 }}
</Title> styles={{ body: { padding: "8px 12px" } }}
{client.attributes.logoUri && (
<img
src={client.attributes.logoUri}
alt={client.name || client.clientId}
style={{ maxHeight: "64px", maxWidth: "100%", marginBottom: "5px" }}
/>
)}
</Flex>
{(() => {
const node =
auth !== undefined && auth.showUsername && !auth.showResetCredentials ? (
<Card
style={{ display: "flex", alignItems: "center", marginBottom: "24px", marginTop: "8px" }}
styles={{ body: { padding: "8px 12px" } }}
>
<Flex gap={"small"}>
<img
src={"https://cdn.tombutcher.work/icons/auth/c-person.svg"}
width={14}
style={{ marginTop: "0px" }}
/>
<Text strong>{auth.attemptedUsername}</Text>
<Link href={url.loginRestartFlowUrl} style={{ marginLeft: "12px" }}>
{msg("restartLoginTooltip")}
</Link>
</Flex>
</Card>
) : null;
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")} <Flex gap={"small"}>
</Button> <img
</div> src={"https://cdn.tombutcher.work/icons/auth/c-person.svg"}
</form> width={14}
)} style={{ marginTop: "0px" }}
/>
<Text strong>{auth.attemptedUsername}</Text>
<Link href={url.loginRestartFlowUrl} style={{ marginLeft: "12px" }}>
{msg("restartLoginTooltip")}
</Link>
</Flex>
</Card>
{isMobile && <Divider style={{ margin: "4px 0" }} />}
</>
)}
{/* App-initiated actions should not see warning messages about the need to complete the action during login. */}
{showMessage && (
<>
<Alert
message={<span dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />}
type={
message.type === "error"
? "error"
: message.type === "success"
? "success"
: message.type === "warning"
? "warning"
: "info"
}
showIcon
style={{ margin: 0, width: "100%" }}
/>
{isMobile && <Divider style={{ margin: "4px 0" }} />}
</>
)}
</Flex>
{displayInfo && <div style={{ marginTop: "24px", textAlign: "center" }}>{infoNode}</div>} <Flex vertical style={{ height: "100%" }} gap={"middle"}>
{children}
{isMobile && (
<Flex gap={"large"} justify="center">
{client.name && <FooterCard darkMode={darkMode}>{clientInfo}</FooterCard>}
{auth !== undefined && auth.showTryAnotherWayLink && (
<FooterCard darkMode={darkMode}>{showTryAnotherWayLink}</FooterCard>
)}
{displayInfo && <FooterCard darkMode={darkMode}>{infoNode}</FooterCard>}
</Flex>
)}
</Flex>
</Flex> </Flex>
</Card> </Card>
{!isMobile && (
<Flex gap={"middle"}>
{client.name && <FooterCard darkMode={darkMode}>{clientInfo}</FooterCard>}
{auth !== undefined && auth.showTryAnotherWayLink && (
<FooterCard darkMode={darkMode}>{showTryAnotherWayLink}</FooterCard>
)}
{displayInfo && <FooterCard darkMode={darkMode}>{infoNode}</FooterCard>}
</Flex>
)}
{!isMobile && ( {!isMobile && (
<Flex style={{ zIndex: 1 }} gap={"large"}> <Flex style={{ zIndex: 1 }} gap={"large"}>
<Text style={{ color: "#ffffff", fontWeight: 700 }}>© 2025</Text> <Text style={{ color: "#ffffff", fontWeight: 700 }}>© 2025</Text>
@ -277,3 +312,27 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</ConfigProvider> </ConfigProvider>
); );
} }
function FooterCard({ children, darkMode = false }: { children?: ReactNode; darkMode?: boolean }) {
const isMobile = useMediaQuery({ maxWidth: 600 });
return (
<Card
style={{
background: isMobile ? "unset" : darkMode ? "#00000025" : "#fffffff2",
borderRadius: "20px",
zIndex: 1,
padding: isMobile ? "unset" : "10px 18px",
boxShadow: isMobile ? "unset" : darkMode ? "0px 5px 20px 5px rgb(255 255 255 / 5%)" : "0px 5px 15px 5px rgb(0 0 0 / 10%)"
}}
variant="borderless"
styles={{
body: {
padding: 0,
height: "100%"
}
}}
>
{children}
</Card>
);
}

View File

@ -11,7 +11,7 @@ import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFo
import type { Attribute } from "keycloakify/login/KcContext"; import type { Attribute } from "keycloakify/login/KcContext";
import type { KcContext } from "./KcContext"; import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n"; import type { I18n } from "./i18n";
import { Form, FormItemProps, Input, InputProps, Typography, Select, Radio, Checkbox, Button, Space, Divider } from "antd"; import { Form, FormItemProps, Input, InputProps, Typography, Select, Radio, Checkbox, Button, Space, Divider, Flex } from "antd";
import { EyeOutlined, EyeInvisibleOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons"; import { EyeOutlined, EyeInvisibleOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
const { Text, Title } = Typography; const { Text, Title } = Typography;
@ -36,68 +36,72 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
const groupNameRef = { current: "" }; const groupNameRef = { current: "" };
return ( return (
<> <Flex vertical gap={"middle"}>
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => { {formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
return ( return (
<> <>
<GroupLabel attribute={attribute} groupNameRef={groupNameRef} i18n={i18n} /> <GroupLabel attribute={attribute} groupNameRef={groupNameRef} i18n={i18n} />
<Form.Item <Flex gap={"5px"} vertical>
label={advancedMsg(attribute.displayName ?? "")} <Text>
validateStatus={displayableErrors.length > 0 ? "error" : undefined} {advancedMsg(attribute.displayName ?? "")}
help={ <Text type="danger"> *</Text>
<> </Text>
{attribute.annotations.inputHelperTextBefore !== undefined && ( <Form.Item
<div id={`form-help-text-before-${attribute.name}`} aria-live="polite"> style={{ margin: 0 }}
{advancedMsg(attribute.annotations.inputHelperTextBefore)} validateStatus={displayableErrors.length > 0 ? "error" : undefined}
</div> rules={[{ required: true }]}
)} name={attribute.name}
<FieldErrors attribute={attribute} displayableErrors={displayableErrors} fieldIndex={undefined} /> shouldUpdate
{attribute.annotations.inputHelperTextAfter !== undefined && ( key={attribute.name}
<div id={`form-help-text-after-${attribute.name}`} aria-live="polite"> {...rest}
{advancedMsg(attribute.annotations.inputHelperTextAfter)} >
</div> {BeforeField !== undefined && (
)} <BeforeField
</> attribute={attribute}
} dispatchFormAction={dispatchFormAction}
rules={[{ required: true }]} displayableErrors={displayableErrors}
name={attribute.name} valueOrValues={valueOrValues}
shouldUpdate kcClsx={kcClsx}
key={attribute.name} i18n={i18n}
{...rest} />
> )}
{BeforeField !== undefined && (
<BeforeField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
valueOrValues={valueOrValues}
kcClsx={kcClsx}
i18n={i18n}
/>
)}
<InputFieldByType <InputFieldByType
attribute={attribute}
valueOrValues={valueOrValues}
displayableErrors={displayableErrors}
dispatchFormAction={dispatchFormAction}
i18n={i18n}
/>
{AfterField !== undefined && (
<AfterField
attribute={attribute} attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
valueOrValues={valueOrValues} valueOrValues={valueOrValues}
kcClsx={kcClsx} displayableErrors={displayableErrors}
dispatchFormAction={dispatchFormAction}
i18n={i18n} i18n={i18n}
/> />
)} {AfterField !== undefined && (
</Form.Item> <AfterField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
valueOrValues={valueOrValues}
kcClsx={kcClsx}
i18n={i18n}
/>
)}
</Form.Item>
<Text type="danger">
{attribute.annotations.inputHelperTextBefore !== undefined && (
<div id={`form-help-text-before-${attribute.name}`} aria-live="polite">
{advancedMsg(attribute.annotations.inputHelperTextBefore)}
</div>
)}
<FieldErrors attribute={attribute} displayableErrors={displayableErrors} fieldIndex={undefined} />
{attribute.annotations.inputHelperTextAfter !== undefined && (
<div id={`form-help-text-after-${attribute.name}`} aria-live="polite">
{advancedMsg(attribute.annotations.inputHelperTextAfter)}
</div>
)}
</Text>
</Flex>
</> </>
); );
})} })}
</> </Flex>
); );
} }

View File

@ -40,8 +40,20 @@ h2 span {
background: rgb(110, 0, 255); background: rgb(110, 0, 255);
background: linear-gradient( background: linear-gradient(
45deg, 45deg,
rgba(110, 0, 255, 1) 0%,
rgba(212, 0, 255, 1) 100% rgb(255, 0, 153) 0%,
rgba(110, 0, 255) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.dark > h2, .dark > span {
background: rgb(110, 0, 255);
background: linear-gradient(
45deg,
rgb(255, 0, 115) 0%,
rgb(170, 0, 255) 100%
); );
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
@ -57,8 +69,8 @@ a.ant-typography,
background: rgb(110, 0, 255) !important; background: rgb(110, 0, 255) !important;
background: linear-gradient( background: linear-gradient(
45deg, 45deg,
rgba(110, 0, 255, 1) 0%, rgb(170, 0, 255) 0%,
rgba(212, 0, 255, 1) 100% rgb(255, 0, 115) 100%
) !important ; ) !important ;
-webkit-background-clip: text !important; -webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important; -webkit-text-fill-color: transparent !important;
@ -192,15 +204,36 @@ a.ant-typography,
background: transparent !important; background: transparent !important;
} }
input:-webkit-autofill, /* Autofill fix for light mode */
input:-webkit-autofill:hover, @media (prefers-color-scheme: light) {
input:-webkit-autofill:focus, input:-webkit-autofill,
input:-webkit-autofill:active, input:-webkit-autofill:hover,
input:-internal-autofill-selected { input:-webkit-autofill:focus,
-webkit-background-clip: text; input:-webkit-autofill:active,
-webkit-text-fill-color: #7e8500; input:-internal-autofill-selected,
box-shadow: inset 0 0 20px 20px #ffffff !important; input:-internal-autofill-previewed {
background-color: green !important box-shadow: inset 0 0 20px 20px #ffffff00 !important;
-webkit-text-fill-color: #000 !important; /* black text in light mode */
transition: background-color 5000s ease-in-out 0s;
}
}
/* Autofill fix for dark mode */
@media (prefers-color-scheme: dark) {
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active,
input:-internal-autofill-selected,
input:-internal-autofill-previewed {
box-shadow: inset 0 0 20px 20px #ffffff00 !important;
-webkit-text-fill-color: #fff !important; /* white text in dark mode */
transition: background-color 5000s ease-in-out 0s;
}
}
.ant-input-outlined:has(input:-webkit-autofill) {
border-color: rgb(250, 173, 20);
} }
input[type="password"] { input[type="password"] {

View File

@ -247,6 +247,10 @@
format("opentype"); format("opentype");
} }
h2 {
line-height: 1 !important;
}
p, p,
span, span,
h1, h1,

View File

@ -66,14 +66,14 @@ export default function DeleteAccountConfirm(props: PageProps<Extract<KcContext,
<Text type="secondary"> {item}</Text> <Text type="secondary"> {item}</Text>
</List.Item> </List.Item>
)} )}
style={{ margin: "16px 0" }} style={{ margin: "18px 0" }}
/> />
<Text strong style={{ marginTop: 8 }}> <Text strong style={{ marginTop: 8 }}>
{msg("finalDeletionConfirmation")} {msg("finalDeletionConfirmation")}
</Text> </Text>
<Flex gap={"middle"} style={{ marginTop: "20px" }}> <Flex gap={"middle"} style={{ marginTop: "18px" }}>
<Button <Button
type="primary" type="primary"
style={{ flexGrow: 2 }} style={{ flexGrow: 2 }}

View File

@ -1,8 +1,10 @@
import { Button, Flex, Alert } from "antd"; import { Button, Flex, 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";
const { Text } = Typography;
export default function DeleteCredential(props: PageProps<Extract<KcContext, { pageId: "delete-credential.ftl" }>, I18n>) { export default function DeleteCredential(props: PageProps<Extract<KcContext, { pageId: "delete-credential.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { msgStr, msg } = i18n; const { msgStr, msg } = i18n;
@ -18,16 +20,10 @@ export default function DeleteCredential(props: PageProps<Extract<KcContext, { p
headerNode={msg("deleteCredentialTitle", credentialLabel)} headerNode={msg("deleteCredentialTitle", credentialLabel)}
> >
<div style={{ margin: "0 15px" }}> <div style={{ margin: "0 15px" }}>
<Alert <Text>{msg("deleteCredentialMessage", credentialLabel)}</Text>
message={msg("deleteCredentialMessage", credentialLabel)}
type="warning"
showIcon
style={{
marginBottom: 26
}}
/>
<form action={url.loginAction} method="POST"> <form action={url.loginAction} method="POST">
<Flex gap="middle"> <Flex gap="18px" style={{ marginTop: "22px" }}>
<Button <Button
type="primary" type="primary"
style={{ flexGrow: 2 }} style={{ flexGrow: 2 }}

View File

@ -2,7 +2,9 @@ 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, Space} from "antd"; import { Flex, Button, Typography } from "antd";
const { Text } = 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,16 +20,17 @@ export default function Error(props: PageProps<Extract<KcContext, { pageId: "err
displayMessage={false} displayMessage={false}
headerNode={msg("errorTitle")} headerNode={msg("errorTitle")}
> >
<div style={{ margin: "0 15px" }}> <Flex style={{ margin: "0 15px" }} vertical>
<Space direction="vertical" size="middle" style={{ marginBottom: 24 }}> <Text type="danger">
<Alert message={<div dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />} type="error" showIcon /> <div dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />
</Space> </Text>
{!skipLink && client !== undefined && client.baseUrl !== undefined && ( {!skipLink && client !== undefined && client.baseUrl !== undefined && (
<Button type="primary" id="backToApplication" size={"large"} block href={client.baseUrl}> <Button type="primary" id="backToApplication" size={"large"} block href={client.baseUrl} style={{ marginTop: "22px" }}>
{msg("backToApplication")} {msg("backToApplication")}
</Button> </Button>
)} )}
</div> </Flex>
</Template> </Template>
); );
} }

View File

@ -13,7 +13,15 @@ export default meta;
type Story = StoryObj<typeof meta>; type Story = StoryObj<typeof meta>;
export const Default: Story = { export const Default: Story = {
render: () => <KcPageStory /> render: () => (
<KcPageStory
kcContext={{
logout: {
logoutRedirectUri: "#"
}
}}
/>
)
}; };
export const WithoutRedirectUrl: Story = { export const WithoutRedirectUrl: Story = {
render: () => ( render: () => (

View File

@ -1,10 +1,10 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Button, List, Typography, Space } from "antd"; import { Button, List, Typography, 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";
const { Paragraph } = Typography; const { Text } = Typography;
export default function FrontchannelLogout(props: PageProps<Extract<KcContext, { pageId: "frontchannel-logout.ftl" }>, I18n>) { export default function FrontchannelLogout(props: PageProps<Extract<KcContext, { pageId: "frontchannel-logout.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -26,8 +26,8 @@ export default function FrontchannelLogout(props: PageProps<Extract<KcContext, {
documentTitle={msgStr("frontchannel-logout.title")} documentTitle={msgStr("frontchannel-logout.title")}
headerNode={msg("frontchannel-logout.title")} headerNode={msg("frontchannel-logout.title")}
> >
<Space direction="vertical" style={{ width: "100%", padding: "0 15px" }}> <Flex vertical style={{ width: "100%", padding: "0 15px" }} gap={"middle"}>
<Paragraph>{msg("frontchannel-logout.message")}</Paragraph> <Text>{msg("frontchannel-logout.message")}</Text>
<List <List
dataSource={logout.clients} dataSource={logout.clients}
@ -41,11 +41,11 @@ export default function FrontchannelLogout(props: PageProps<Extract<KcContext, {
/> />
{logout.logoutRedirectUri && ( {logout.logoutRedirectUri && (
<Button type="primary" id="continue" href={logout.logoutRedirectUri}> <Button type="primary" id="continue" href={logout.logoutRedirectUri} size="large">
{msg("doContinue")} {msg("doContinue")}
</Button> </Button>
)} )}
</Space> </Flex>
</Template> </Template>
); );
} }

View File

@ -1,4 +1,4 @@
import { Typography, Button, Space } from "antd"; import { Typography, Button, 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";
@ -29,7 +29,7 @@ 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 }}> <Button type="primary" block size={"large"} href={pageRedirectUri}>
{msg("backToApplication")} {msg("backToApplication")}
</Button> </Button>
); );
@ -38,7 +38,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
if (actionUri) { if (actionUri) {
return ( return (
<> <>
<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>
</> </>
@ -48,7 +48,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
if (client.baseUrl) { if (client.baseUrl) {
return ( return (
<> <>
<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>
</> </>
@ -61,13 +61,12 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
return ( return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} displayMessage={false} headerNode={messageHeader}> <Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} displayMessage={false} headerNode={messageHeader}>
<div style={{ padding: "0 15px" }}> <div style={{ padding: "0 15px" }}>
<Space direction="vertical" size="middle"> <Flex vertical gap="18px">
<Text className="instruction"> <Text className="instruction">
<span dangerouslySetInnerHTML={{ __html: getMessageContent() }}></span> <span dangerouslySetInnerHTML={{ __html: getMessageContent() }}></span>
</Text> </Text>
</Space> {renderActionLink()}
</Flex>
{renderActionLink()}
</div> </div>
</Template> </Template>
); );

View File

@ -70,12 +70,7 @@ export default function Login(props: PageProps<Extract<KcContext, { pageId: "log
doUseDefaultCss={false} doUseDefaultCss={false}
headerNode={msg("loginAccountTitle")} headerNode={msg("loginAccountTitle")}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled} displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
infoNode={ infoNode={<Link href={url.registrationUrl}>{msg("doRegister")}</Link>}
<Space>
<Text>{msg("noAccount")}</Text>
<Link href={url.registrationUrl}>{msg("doRegister")}</Link>
</Space>
}
socialProvidersNode={ socialProvidersNode={
<> <>
{realm.password && social?.providers !== undefined && social.providers.length !== 0 && ( {realm.password && social?.providers !== undefined && social.providers.length !== 0 && (

View File

@ -20,7 +20,16 @@ export const WithManualSetUp: Story = {
render: () => ( render: () => (
<KcPageStory <KcPageStory
kcContext={{ kcContext={{
mode: "manual" mode: "manual",
client: {
attributes: {
logoUri: "https://cdn.tombutcher.work/logos/grafana-logo.png",
policyUri: "https://twitter.com/en/tos",
tosUri: "https://twitter.com/en/privacy"
},
name: "Grafana",
clientId: "twitter-client-id"
}
}} }}
/> />
) )

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { getKcClsx, KcClsx } from "keycloakify/login/lib/kcClsx"; // Removed unused kcClsx utilities
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";
@ -17,10 +17,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
"cancel-aia": string; "cancel-aia": string;
}; };
const { kcClsx } = getKcClsx({ // kcClsx no longer needed after layout changes
doUseDefaultCss,
classes
});
const [isSubmitLoading, setIsSubmitLoading] = useState(false); const [isSubmitLoading, setIsSubmitLoading] = useState(false);
const [isCancelLoading, setIsCancelLoading] = useState(false); const [isCancelLoading, setIsCancelLoading] = useState(false);
@ -79,8 +76,28 @@ 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")}
> >
<Form layout="vertical" onFinish={handleSubmit} style={{ margin: "0 auto" }}> <Form
<div style={{ overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px", marginBottom: 24 }}> onFinish={handleSubmit}
style={{
margin: "0 auto",
height: "100%",
display: "flex",
flexDirection: "column",
flex: 1,
minHeight: 0,
overflow: "hidden"
}}
>
<div
style={{
overflowY: "auto",
maxHeight: isMobile ? undefined : "175px",
padding: "0 15px",
marginBottom: "18px",
flex: 1,
minHeight: 0
}}
>
<Steps <Steps
direction="vertical" direction="vertical"
current={3} current={3}
@ -184,73 +201,67 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
} }
]} ]}
/> />
<Divider /> <Divider style={{ marginTop: "12px" }} />
<Form.Item <Flex vertical gap={"middle"}>
label={ <Flex gap="small" vertical>
<> <Text>
{msg("authenticatorCode")} <span className="required">*</span> {msg("authenticatorCode")} <Text type="danger">*</Text>
</> </Text>
} <Flex gap="small" align="center">
name="totp" <img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />
validateStatus={messagesPerField.existsError("totp") ? "error" : undefined} <Form.Item
help={ name="totp"
messagesPerField.existsError("totp") && ( validateStatus={messagesPerField.existsError("totp") ? "error" : undefined}
<span style={{ margin: 0 }}
id="input-error-otp-code" >
dangerouslySetInnerHTML={{ <Input.OTP id="totp" size="large" aria-invalid={messagesPerField.existsError("totp")} />
__html: kcSanitize(messagesPerField.get("totp")) </Form.Item>
}} </Flex>
{messagesPerField.existsError("totp") && (
<Text id="input-error-otp-code" type={"danger"}>
{kcSanitize(messagesPerField.get("totp"))}
</Text>
)}
</Flex>
<Flex gap="small" vertical>
<Text>
{msg("loginTotpDeviceName")} {totp.otpCredentials.length >= 1 && <Text type="danger">*</Text>}
</Text>
<Form.Item
name="userLabel"
validateStatus={messagesPerField.existsError("userLabel") ? "error" : undefined}
style={{ margin: 0 }}
>
<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>
} {messagesPerField.existsError("userLabel") && (
> <Text id="input-error-otp-code" type={"danger"}>
<Input {kcSanitize(messagesPerField.get("userLabel"))}
id="totp" </Text>
autoComplete="off" )}
size="large" </Flex>
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />} </Flex>
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>
<div style={{ padding: "0 15px" }}> <div style={{ padding: "0 15px" }}>
<Form.Item> <Form.Item style={{ margin: 0 }}>
<LogoutOtherSessions kcClsx={kcClsx} i18n={i18n} /> <Checkbox id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true}>
{msg("logoutOtherSessions")}
</Checkbox>
</Form.Item> </Form.Item>
<Form.Item> <Form.Item style={{ margin: 0 }}>
<Flex gap={"middle"}> <Flex gap={"middle"}>
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
style={{ flexGrow: 2 }} style={{ flexGrow: 2, marginTop: "22px" }}
size="large" size="large"
id="saveTOTPBtn" id="saveTOTPBtn"
iconPosition="end" iconPosition="end"
@ -287,14 +298,3 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
</Template> </Template>
); );
} }
function LogoutOtherSessions(props: { kcClsx: KcClsx; i18n: I18n }) {
const { i18n } = props;
const { msg } = i18n;
return (
<Checkbox id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true}>
{msg("logoutOtherSessions")}
</Checkbox>
);
}

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Typography, Form, Input } from "antd"; import { Button, Typography, Form, Input, Flex } 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";
@ -44,40 +44,37 @@ 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} style={{ padding: "0 15px" }}> <Form id="kc-user-verify-device-user-code-form" onFinish={handleSubmit} style={{ padding: "0 15px" }}>
<Text>{msg("verifyOAuth2DeviceUserCode")}</Text> <Flex gap="18px" vertical>
<Form.Item label={"Code"} name="device_user_code" rules={[{ required: true, message: "Required" }]}> <Text>{msg("verifyOAuth2DeviceUserCode")}</Text>
<Input <Flex gap="small" align="center" style={{ marginBottom: "10px" }}>
id="device-user-code" <img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />
name="device_user_code" <Form.Item name="device_user_code" rules={[{ required: true, message: "Required" }]}>
style={{ marginBottom: "10px" }} <Input.OTP id="device-user-code" autoFocus size="large" />
autoComplete="off" </Form.Item>
autoFocus </Flex>
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />}
/>
</Form.Item>
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
block block
size="large" size="large"
iconPosition="end" iconPosition="end"
icon={ icon={
<img <img
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"} src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
style={{ marginTop: "3px", marginBottom: "0px" }} style={{ marginTop: "3px", marginBottom: "0px" }}
width={14} width={14}
/> />
} }
loading={isSubmitLoading} loading={isSubmitLoading}
disabled={isSubmitLoading} disabled={isSubmitLoading}
> >
{msgStr("doSubmit")} {msgStr("doSubmit")}
</Button> </Button>
</Form.Item> </Form.Item>
</Flex>
</Form> </Form>
</Template> </Template>
); );

View File

@ -16,8 +16,8 @@ const mockKcContext = {
policyUri: "https://twitter.com/en/tos", policyUri: "https://twitter.com/en/tos",
tosUri: "https://twitter.com/en/privacy" tosUri: "https://twitter.com/en/privacy"
}, },
name: "Twitter", name: "Grafana",
clientId: "twitter-client-id" clientId: "grafana-client-id"
} }
}; };

View File

@ -50,14 +50,14 @@ export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pa
headerNode={client.name ? msg("oauthGrantTitle", advancedMsgStr(client.name)) : msg("oauthGrantTitle", client.clientId)} headerNode={client.name ? msg("oauthGrantTitle", advancedMsgStr(client.name)) : msg("oauthGrantTitle", client.clientId)}
> >
<div style={{ margin: "0 15px" }}> <div style={{ margin: "0 15px" }}>
<Alert message={msg("oauthGrantRequest")} type="warning" style={{ marginBottom: "20px" }} showIcon></Alert> <Alert message={msg("oauthGrantRequest")} type="warning" style={{ marginBottom: "18px" }} showIcon></Alert>
{(client.attributes.policyUri || client.attributes.tosUri) && ( {(client.attributes.policyUri || client.attributes.tosUri) && (
<Alert <Alert
message={ message={
client.name ? msg("oauthGrantInformation", advancedMsgStr(client.name)) : msg("oauthGrantInformation", client.clientId) client.name ? msg("oauthGrantInformation", advancedMsgStr(client.name)) : msg("oauthGrantInformation", client.clientId)
} }
type="info" type="info"
style={{ marginBottom: "20px" }} style={{ marginBottom: "18px" }}
showIcon showIcon
></Alert> ></Alert>
)} )}
@ -84,11 +84,12 @@ export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pa
</Space> </Space>
</List.Item> </List.Item>
)} )}
style={{ marginBottom: "18px" }}
/> />
)} )}
{(client.attributes.policyUri || client.attributes.tosUri) && ( {(client.attributes.policyUri || client.attributes.tosUri) && (
<div style={{ marginTop: 10 }}> <div style={{ marginBottom: "22px" }}>
<Space direction="vertical" style={{ marginTop: "12px" }}> <Space direction="vertical">
{client.attributes.tosUri && ( {client.attributes.tosUri && (
<Text> <Text>
{msg("oauthGrantReview")}{" "} {msg("oauthGrantReview")}{" "}
@ -108,7 +109,7 @@ export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pa
</Space> </Space>
</div> </div>
)} )}
<Flex gap="middle" style={{ marginTop: 24 }}> <Flex gap="18px">
<Button <Button
type="primary" type="primary"
style={{ flexGrow: 1 }} style={{ flexGrow: 1 }}

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Form, Input, Button, Radio, List, Typography, Space } from "antd"; import { Form, Input, Button, Radio, List, Typography, Space, Flex } 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";
@ -72,30 +72,21 @@ export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "
</Form.Item> </Form.Item>
)} )}
<Form.Item <Flex gap="small" vertical>
label={msg("loginOtpOneTime")} <Text>{msg("authenticatorCode")}</Text>
validateStatus={messagesPerField.existsError("totp") ? "error" : ""} <Flex gap="small" align="center">
name="otp" <img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />
help={ <Form.Item validateStatus={messagesPerField.existsError("totp") ? "error" : ""} name="otp" style={{ margin: 0 }}>
messagesPerField.existsError("totp") && ( <Input.OTP id="otp" autoFocus aria-invalid={messagesPerField.existsError("totp")} size="large" />
<span </Form.Item>
id="input-error-otp-code" </Flex>
dangerouslySetInnerHTML={{ {messagesPerField.existsError("totp") && (
__html: kcSanitize(messagesPerField.get("totp")) <Text id="input-error-otp-code" type="danger">
}} {kcSanitize(messagesPerField.get("totp"))}
/> </Text>
) )}
} </Flex>
>
<Input
id="otp"
autoComplete="off"
autoFocus
aria-invalid={messagesPerField.existsError("totp")}
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />}
/>
</Form.Item>
<Form.Item> <Form.Item>
<Button <Button
type="primary" type="primary"
@ -106,7 +97,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 }} style={{ marginTop: "22px" }}
iconPosition={"end"} iconPosition={"end"}
icon={ icon={
<img <img

View File

@ -93,7 +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 }} style={{ marginTop: 27 }}
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

@ -34,11 +34,12 @@ export default function LoginRecoveryAuthnCodeConfig(props: PageProps<Extract<Kc
> >
<div style={{ margin: "0 15px" }}> <div style={{ margin: "0 15px" }}>
<Space direction="vertical" size="middle"> <Space direction="vertical" size="middle">
<Space direction="vertical" size="large"> <Flex vertical>
<Alert message={msg("recovery-code-config-warning-title")} type="warning" showIcon /> <Alert message={msg("recovery-code-config-warning-title")} type="warning" showIcon style={{ marginBottom: "18px" }} />
<Text>{msg("recovery-code-config-warning-message")}</Text> <Text>{msg("recovery-code-config-warning-message")}</Text>
</Space> </Flex>
<Flex id={olRecoveryCodesListId} gap="middle" wrap>
<Flex id={olRecoveryCodesListId} gap="18px" wrap>
{recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList.map((code, index) => ( {recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList.map((code, index) => (
<Text key={index} strong code style={{ fontSize: "18px" }}> <Text key={index} strong code style={{ fontSize: "18px" }}>
{formatRecoveryCode(code)} {formatRecoveryCode(code)}
@ -58,58 +59,47 @@ export default function LoginRecoveryAuthnCodeConfig(props: PageProps<Extract<Kc
</Button> </Button>
</Space> </Space>
<Checkbox <Form id="kc-recovery-codes-settings-form" method="post" action={kcContext.url.loginAction}>
id="kcRecoveryCodesConfirmationCheck" <Input
name="kcRecoveryCodesConfirmationCheck" type="hidden"
onChange={e => setIsConfirmed(e.target.checked)} name="generatedRecoveryAuthnCodes"
style={{ marginBottom: "0px" }} value={recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString}
> />
{msg("recovery-codes-confirmation-message")} <Input type="hidden" name="generatedAt" value={recoveryAuthnCodesConfigBean.generatedAt} />
</Checkbox> <Input type="hidden" id="userLabel" name="userLabel" value={msgStr("recovery-codes-label-default")} />
</Space>
<Form id="kc-recovery-codes-settings-form" method="post" action={kcContext.url.loginAction}> <Checkbox
<Input id="kcRecoveryCodesConfirmationCheck"
type="hidden" name="kcRecoveryCodesConfirmationCheck"
name="generatedRecoveryAuthnCodes" onChange={e => setIsConfirmed(e.target.checked)}
value={recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesAsString} style={{ marginBottom: "9px" }}
/> >
<Input type="hidden" name="generatedAt" value={recoveryAuthnCodesConfigBean.generatedAt} /> {msg("recovery-codes-confirmation-message")}
<Input type="hidden" id="userLabel" name="userLabel" value={msgStr("recovery-codes-label-default")} /> </Checkbox>
<LogoutOtherSessions i18n={i18n} /> <Checkbox id="logout-sessions" name="logout-sessions" value="on" defaultChecked style={{ marginBottom: "22px" }}>
{msg("logoutOtherSessions")}
</Checkbox>
<div style={{ marginTop: 24 }}> <div>
{isAppInitiatedAction ? ( {isAppInitiatedAction ? (
<Space> <Space>
<Button type="primary" size="large" id="saveRecoveryAuthnCodesBtn" htmlType="submit" disabled={!isConfirmed}> <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")} {msg("recovery-codes-action-complete")}
</Button> </Button>
<Button size="large" id="cancelRecoveryAuthnCodesBtn" name="cancel-aia" value="true" htmlType="submit"> )}
{msg("recovery-codes-action-cancel")} </div>
</Button> </Form>
</Space> </Space>
) : (
<Button type="primary" size="large" id="saveRecoveryAuthnCodesBtn" htmlType="submit" disabled={!isConfirmed} block>
{msg("recovery-codes-action-complete")}
</Button>
)}
</div>
</Form>
</div> </div>
</Template> </Template>
); );
} }
function LogoutOtherSessions(props: { i18n: I18n }) {
const { i18n } = props;
const { msg } = i18n;
return (
<div style={{ marginTop: "16px" }}>
<Checkbox id="logout-sessions" name="logout-sessions" value="on" defaultChecked>
{msg("logoutOtherSessions")}
</Checkbox>
</div>
);
}

View File

@ -32,6 +32,7 @@ export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcC
<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" : ""}
style={{ margin: 0 }}
help={ help={
hasError ? ( hasError ? (
<span <span
@ -48,7 +49,7 @@ export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcC
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" block size="large" id="kc-login" name="login" style={{ marginTop: 12 }}> <Button type="primary" htmlType="submit" block size="large" id="kc-login" name="login" style={{ marginTop: "22px" }}>
{msgStr("doLogIn")} {msgStr("doLogIn")}
</Button> </Button>
</Form.Item> </Form.Item>

View File

@ -1,4 +1,4 @@
import { Form, Radio, Button, Typography, Space } from "antd"; import { Form, Radio, Button, Typography, List, 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";
@ -20,40 +20,38 @@ export default function LoginResetOtp(props: PageProps<Extract<KcContext, { page
headerNode={msg("doLogIn")} headerNode={msg("doLogIn")}
> >
<Form id="kc-otp-reset-form" layout="vertical" method="post" action={url.loginAction} style={{ padding: "0 15px" }}> <Form id="kc-otp-reset-form" layout="vertical" method="post" action={url.loginAction} style={{ padding: "0 15px" }}>
<Space direction="vertical" style={{ width: "100%" }}> <Flex vertical style={{ width: "100%" }}>
<Text id="kc-otp-reset-form-description">{msg("otp-reset-description")}</Text> <Flex vertical gap={"middle"} style={{ marginBottom: "22px" }}>
<Text id="kc-otp-reset-form-description">{msg("otp-reset-description")}</Text>
<Form.Item name="selectedCredentialId"> <Form.Item name="selectedCredentialId">
<Radio.Group defaultValue={configuredOtpCredentials.selectedCredentialId || undefined} style={{ width: "100%" }}> <Radio.Group defaultValue={configuredOtpCredentials.selectedCredentialId} style={{ width: "100%" }}>
{configuredOtpCredentials.userOtpCredentials.map((otpCredential, index) => ( <List
<Radio dataSource={configuredOtpCredentials.userOtpCredentials}
key={otpCredential.id} bordered
value={otpCredential.id} style={{ width: "100%", lineHeight: 1 }}
id={`kc-otp-credential-${index}`} renderItem={(otpCredential, index) => (
style={{ <List.Item key={index}>
display: "block", <Flex align="center" gap={"small"} justify="space-between">
margin: "8px 0" <Radio id={`kc-otp-credential-${index}`} value={otpCredential.id} style={{ display: "block" }}>
}} <Text style={{ position: "relative", top: "-3px" }}>{otpCredential.userLabel}</Text>
> </Radio>
<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"} style={{ marginRight: "3px", height: "16px" }}
width={14} />
style={{ marginRight: "3px", marginBottom: "5px" }} </Flex>
/> </List.Item>
<Text>{otpCredential.userLabel}</Text> )}
</Space> />
</Radio> </Radio.Group>
))} </Form.Item>
</Radio.Group> </Flex>
</Form.Item>
<Form.Item> <Form.Item>
<Button id="kc-otp-reset-form-submit" type="primary" htmlType="submit" block size="large"> <Button id="kc-otp-reset-form-submit" type="primary" htmlType="submit" block size="large">
{msgStr("doSubmit")} {msgStr("doSubmit")}
</Button> </Button>
</Form.Item> </Form.Item>
</Space> </Flex>
</Form> </Form>
</Template> </Template>
); );

View File

@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Form, Input, Button, Alert, Space, Typography } from "antd"; import { Form, Input, Button, Alert, 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";
@ -43,7 +43,8 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
kcContext={kcContext} kcContext={kcContext}
i18n={i18n} i18n={i18n}
doUseDefaultCss={false} doUseDefaultCss={false}
infoNode={false} infoNode={<Link href={url.loginUrl}>{msg("backToLogin")}</Link>}
displayInfo={true}
displayMessage={!messagesPerField.existsError("username")} displayMessage={!messagesPerField.existsError("username")}
headerNode={msg("emailForgotTitle")} headerNode={msg("emailForgotTitle")}
> >
@ -57,7 +58,7 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
method="post" method="post"
style={{ padding: "0 15px" }} style={{ padding: "0 15px" }}
> >
{displayInfo && displayMessage && <Alert message={infoMessage} type="info" showIcon style={{ marginBottom: 24 }} />} {displayInfo && displayMessage && <Alert message={infoMessage} type="info" showIcon style={{ marginBottom: "18px" }} />}
<Form.Item <Form.Item
label={inputLabel} label={inputLabel}
@ -84,15 +85,11 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
/> />
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 22, marginTop: 26 }}> <Form.Item style={{ marginBottom: 22, marginTop: "22px" }}>
<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" }}>
<Link href={url.loginUrl}>{msg("backToLogin")}</Link>
</Space>
</Form> </Form>
</Template> </Template>
); );

View File

@ -1,10 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Form, Input, Checkbox, Flex } from "antd"; import { Button, Form, Input, Checkbox, Flex, Typography } 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 { Text } = Typography;
type FieldType = { type FieldType = {
"password-new": string; "password-new": string;
"password-confirm": string; "password-confirm": string;
@ -57,51 +59,67 @@ export default function LoginUpdatePassword(props: PageProps<Extract<KcContext,
headerNode={msg("updatePasswordTitle")} headerNode={msg("updatePasswordTitle")}
> >
<Form id="kc-passwd-update-form" layout="vertical" onFinish={handleSubmit} style={{ padding: "0 15px" }}> <Form id="kc-passwd-update-form" layout="vertical" onFinish={handleSubmit} style={{ padding: "0 15px" }}>
<Form.Item <Flex style={{ marginBottom: "22px" }} vertical gap={"middle"}>
label={msg("passwordNew")} <Flex vertical gap={"5px"}>
name="password-new" <Text>
rules={[{ required: true, message: messagesPerField.get("password-confirm") }]} {msg("passwordNew")}
validateStatus={messagesPerField.existsError("password") ? "error" : ""} <Text type="danger"> *</Text>
help={messagesPerField.existsError("password") ? messagesPerField.get("password") : ""} </Text>
> <Form.Item
<Input.Password name="password-new"
autoFocus style={{ margin: 0 }}
autoComplete="new-password" rules={[{ required: true, message: messagesPerField.get("password-confirm") }]}
size="large" validateStatus={messagesPerField.existsError("password") ? "error" : ""}
prefix={<img src="https://cdn.tombutcher.work/icons/auth/c-lock.svg" style={{ marginRight: "3px" }} height={14} />} >
aria-invalid={messagesPerField.existsError("password", "password-confirm")} <Input.Password
iconRender={visible => (visible ? <EyeOutlined /> : <EyeInvisibleOutlined />)} autoFocus
/> autoComplete="new-password"
</Form.Item> size="large"
prefix={<img src="https://cdn.tombutcher.work/icons/auth/c-lock.svg" style={{ marginRight: "3px" }} height={14} />}
aria-invalid={messagesPerField.existsError("password", "password-confirm")}
iconRender={visible => (visible ? <EyeOutlined /> : <EyeInvisibleOutlined />)}
/>
</Form.Item>
{messagesPerField.existsError("password") ? <Text type="danger">{messagesPerField.get("password")}</Text> : null}
</Flex>
<Form.Item <Flex vertical gap={"5px"}>
label={msg("passwordConfirm")} <Text>
name="password-confirm" {msg("passwordConfirm")}
rules={[{ required: true, message: messagesPerField.get("password-confirm") }]} <Text type="danger"> *</Text>
validateStatus={messagesPerField.existsError("password-confirm") ? "error" : ""} </Text>
help={messagesPerField.existsError("password-confirm") ? messagesPerField.get("password-confirm") : ""} <Form.Item
> name="password-confirm"
<Input.Password style={{ margin: 0 }}
autoComplete="new-password" rules={[{ required: true, message: messagesPerField.get("password-confirm") }]}
size="large" validateStatus={messagesPerField.existsError("password-confirm") ? "error" : ""}
prefix={<img src="https://cdn.tombutcher.work/icons/auth/c-lock.svg" style={{ marginRight: "3px" }} height={14} />} >
aria-invalid={messagesPerField.existsError("password", "password-confirm")} <Input.Password
iconRender={visible => (visible ? <EyeOutlined /> : <EyeInvisibleOutlined />)} autoFocus
/> autoComplete="new-password"
</Form.Item> size="large"
prefix={<img src="https://cdn.tombutcher.work/icons/auth/c-lock.svg" style={{ marginRight: "3px" }} height={14} />}
<Form.Item name="logout-sessions" valuePropName="checked" initialValue={true}> aria-invalid={messagesPerField.existsError("password", "password-confirm")}
<Checkbox>{msg("logoutOtherSessions")}</Checkbox> iconRender={visible => (visible ? <EyeOutlined /> : <EyeInvisibleOutlined />)}
</Form.Item> />
</Form.Item>
{messagesPerField.existsError("password-confirm") ? (
<Text type="danger">{messagesPerField.get("password-confirm")}</Text>
) : null}
</Flex>
<Form.Item name="logout-sessions" valuePropName="checked" initialValue={true} style={{ margin: 0 }}>
<Checkbox>{msg("logoutOtherSessions")}</Checkbox>
</Form.Item>
</Flex>
<Form.Item> <Form.Item>
<Flex gap="middle"> <Flex gap="18px">
<Button <Button
type="primary" type="primary"
size="large" size="large"
htmlType="submit" htmlType="submit"
iconPosition="end" iconPosition="end"
style={{ marginTop: 6, flexGrow: 2 }} style={{ 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"}

View File

@ -1,11 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { Button, Divider, Form, FormProps, Flex } from "antd"; import { Button, Form, FormProps, Flex } 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 { 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";
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";
import Template from "../Template";
type LoginUpdateProfileProps = PageProps<Extract<KcContext, { pageId: "login-update-profile.ftl" }>, I18n> & { type LoginUpdateProfileProps = PageProps<Extract<KcContext, { pageId: "login-update-profile.ftl" }>, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>; UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
@ -16,6 +18,7 @@ export default function LoginUpdateProfile(props: LoginUpdateProfileProps) {
const { kcContext, i18n, UserProfileFormFields, doMakeUserConfirmPassword } = props; const { kcContext, i18n, UserProfileFormFields, doMakeUserConfirmPassword } = props;
const { url, isAppInitiatedAction } = kcContext; const { url, isAppInitiatedAction } = kcContext;
const { msg, msgStr } = i18n; const { msg, msgStr } = i18n;
const isMobile = useMediaQuery({ maxWidth: 600 });
const [form] = Form.useForm(); const [form] = Form.useForm();
@ -59,51 +62,61 @@ export default function LoginUpdateProfile(props: LoginUpdateProfileProps) {
}; };
return ( return (
<Form form={form} layout="vertical" onFinish={handleSubmit}> <Template
<UserProfileFormFields kcContext={kcContext}
kcContext={kcContext} i18n={i18n}
i18n={i18n} doUseDefaultCss={false}
kcClsx={options => (typeof options === "object" && options !== null ? (options as { classKey?: string }).classKey ?? "" : "")} headerNode={i18n.msg("loginProfileTitle")}
doMakeUserConfirmPassword={doMakeUserConfirmPassword} displayMessage={kcContext.messagesPerField.exists("global")}
onIsFormSubmittableValueChange={() => {}} >
/> <Form form={form} layout="vertical" onFinish={handleSubmit}>
<div style={{ overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px" }}>
<UserProfileFormFields
kcContext={kcContext}
i18n={i18n}
kcClsx={options => (typeof options === "object" && options !== null ? (options as { classKey?: string }).classKey ?? "" : "")}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
onIsFormSubmittableValueChange={() => {}}
/>
</div>
<div style={{ padding: "0 15px" }}>
<Form.Item style={{ marginTop: 22 }}>
<Flex gap="18px">
<Button
type="primary"
size="large"
htmlType="submit"
loading={isSubmitLoading}
disabled={isSubmitLoading || isCancelLoading}
iconPosition="end"
style={{ flexGrow: 2 }}
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
style={{ marginTop: "3px", marginBottom: "0px" }}
width={14}
/>
}
>
{msgStr("doSubmit")}
</Button>
<Divider /> {isAppInitiatedAction && (
<Form.Item> <Button
<Flex gap="middle"> size="large"
<Button htmlType="submit"
type="primary" style={{ flexGrow: 2 }}
size="large" loading={isCancelLoading}
htmlType="submit" disabled={isSubmitLoading || isCancelLoading}
loading={isSubmitLoading} onClick={handleCancel}
disabled={isSubmitLoading || isCancelLoading} >
iconPosition="end" {msg("doCancel")}
style={{ flexGrow: 2 }} </Button>
icon={ )}
<img </Flex>
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"} </Form.Item>
style={{ marginTop: "3px", marginBottom: "0px" }} </div>
width={14} </Form>
/> </Template>
}
>
{msgStr("doSubmit")}
</Button>
{isAppInitiatedAction && (
<Button
size="large"
htmlType="submit"
style={{ flexGrow: 2 }}
loading={isCancelLoading}
disabled={isSubmitLoading || isCancelLoading}
onClick={handleCancel}
>
{msg("doCancel")}
</Button>
)}
</Flex>
</Form.Item>
</Form>
); );
} }

View File

@ -22,7 +22,7 @@ export const Default: Story = {
policyUri: "https://twitter.com/en/tos", policyUri: "https://twitter.com/en/tos",
tosUri: "https://twitter.com/en/privacy" tosUri: "https://twitter.com/en/privacy"
}, },
name: "Twitter", name: "Gitea",
clientId: "twitter-client-id" clientId: "twitter-client-id"
} }
}} }}

View File

@ -66,12 +66,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
doUseDefaultCss={false} doUseDefaultCss={false}
headerNode={msg("loginAccountTitle")} headerNode={msg("loginAccountTitle")}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled} displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
infoNode={ infoNode={<Link href={url.registrationUrl}>{msg("doRegister")}</Link>}
<Space>
<Text>{msg("noAccount")}</Text>
<Link href={url.registrationUrl}>{msg("doRegister")}</Link>
</Space>
}
socialProvidersNode={ socialProvidersNode={
<> <>
{realm.password && social?.providers !== undefined && social.providers.length !== 0 && ( {realm.password && social?.providers !== undefined && social.providers.length !== 0 && (

View File

@ -40,7 +40,15 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
}; };
return ( return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} classes={props.classes} headerNode={msg("logoutConfirmTitle")}> <Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={false}
classes={props.classes}
headerNode={msg("logoutConfirmTitle")}
infoNode={<Link href={client.baseUrl}>{msg("backToApplication")}</Link>}
displayInfo={!logoutConfirm.skipLink && client.baseUrl != null}
>
<div id="kc-logout-confirm" style={{ margin: "0 15px" }}> <div id="kc-logout-confirm" style={{ margin: "0 15px" }}>
<div style={{ marginBottom: "25px" }}> <div style={{ marginBottom: "25px" }}>
<Text className="instruction">{msg("logoutConfirmHeader")}</Text> <Text className="instruction">{msg("logoutConfirmHeader")}</Text>
@ -64,11 +72,6 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
> >
{msgStr("doLogout")} {msgStr("doLogout")}
</Button> </Button>
{!logoutConfirm.skipLink && client.baseUrl && (
<div style={{ marginTop: 16, textAlign: "center" }}>
<Link href={client.baseUrl}>{msg("backToApplication")}</Link>
</div>
)}
</div> </div>
</Template> </Template>
); );

View File

@ -1,5 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Form, Button, Checkbox, Typography, Space, FormProps, FormItemProps } from "antd"; import { Form, Button, Checkbox, Typography, Flex, FormProps, FormItemProps } 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 { kcSanitize } from "keycloakify/lib/kcSanitize"; import { kcSanitize } from "keycloakify/lib/kcSanitize";
@ -8,6 +8,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 { useMediaQuery } from "react-responsive"; import { useMediaQuery } from "react-responsive";
import Template from "../Template";
const { Title, Text, Link } = Typography; const { Title, Text, Link } = Typography;
@ -65,71 +66,78 @@ export default function Register(props: RegisterProps) {
}; };
return ( return (
<Form form={form} layout="vertical" name="registerForm" requiredMark onFinish={onFinish}> <Template
<div style={{ overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px" }}> kcContext={kcContext}
<UserProfileFormFields i18n={i18n}
layout="vertical" doUseDefaultCss={false}
kcContext={kcContext} classes={{}}
i18n={i18n} headerNode={kcContext.messageHeader !== undefined ? i18n.advancedMsg(kcContext.messageHeader) : i18n.msg("registerTitle")}
kcClsx={options => (typeof options === "object" && options !== null ? (options as { classKey?: string }).classKey ?? "" : "")} displayMessage={kcContext.messagesPerField.exists("global")}
doMakeUserConfirmPassword={doMakeUserConfirmPassword} displayInfo={true}
onIsFormSubmittableValueChange={() => {}} infoNode={<Link href={url.loginUrl}>{msg("backToLogin")}</Link>}
/> >
</div> <Form form={form} layout="vertical" name="registerForm" requiredMark onFinish={onFinish}>
<div style={{ padding: "0 15px" }}> <div style={{ overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px" }}>
{termsAcceptanceRequired && ( <UserProfileFormFields
<TermsAcceptance layout="vertical"
kcContext={kcContext}
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: 22 }}>
<Flex style={{ width: "100%" }} gap={"middle"}>
{recaptchaRequired && !recaptchaVisible && recaptchaAction !== undefined ? (
<Button
type="primary"
htmlType="submit"
style={{ flexGrow: 1 }}
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"
style={{ flexGrow: 1 }}
size="large"
loading={isLoading}
disabled={!submittable || isLoading || (termsAcceptanceRequired && !areTermsAccepted)}
>
{msgStr("doRegister")}
</Button>
)}
</Flex>
</Form.Item> </Form.Item>
)} </div>
<Form.Item style={{ marginTop: 24 }}> </Form>
<Space direction="vertical" style={{ width: "100%" }}> </Template>
{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>
</div>
</Form>
); );
} }

View File

@ -1,4 +1,4 @@
import { Typography, Button, List, Flex, Space } from "antd"; import { Typography, Button, List, 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";
@ -7,7 +7,7 @@ const { Title, Text } = Typography;
export default function SelectAuthenticator(props: PageProps<Extract<KcContext, { pageId: "select-authenticator.ftl" }>, I18n>) { export default function SelectAuthenticator(props: PageProps<Extract<KcContext, { pageId: "select-authenticator.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props; const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { url, auth, client } = kcContext; const { url, auth } = kcContext;
const { msg, advancedMsg } = i18n; const { msg, advancedMsg } = i18n;
return ( return (
@ -17,24 +17,9 @@ export default function SelectAuthenticator(props: PageProps<Extract<KcContext,
doUseDefaultCss={doUseDefaultCss} doUseDefaultCss={doUseDefaultCss}
classes={classes} classes={classes}
displayInfo={false} displayInfo={false}
headerNode={ headerNode={msg("loginChooseAuthenticator")}
<>
{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("loginChooseAuthenticator")}</Title>
</Space>
) : (
<Title level={3}>{msg("loginChooseAuthenticator")}</Title>
)}
</>
}
> >
<form id="kc-select-credential-form" action={url.loginAction} method="post" style={{ padding: '0 15px'}}> <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"
@ -42,8 +27,7 @@ export default function SelectAuthenticator(props: PageProps<Extract<KcContext,
renderItem={authenticationSelection => ( renderItem={authenticationSelection => (
<List.Item <List.Item
style={{ style={{
padding: 0, padding: 0
}} }}
> >
<Button <Button

View File

@ -44,7 +44,7 @@ export default function Terms(props: PageProps<Extract<KcContext, { pageId: "ter
{/* Scrollable terms box */} {/* Scrollable terms box */}
<Card <Card
style={{ style={{
marginBottom: 26, marginBottom: "22px",
maxHeight: "250px", maxHeight: "250px",
overflow: "auto" overflow: "auto"
}} }}
@ -54,7 +54,7 @@ export default function Terms(props: PageProps<Extract<KcContext, { pageId: "ter
<Paragraph>{msg("termsText")}</Paragraph> <Paragraph>{msg("termsText")}</Paragraph>
</div> </div>
</Card> </Card>
<Flex gap="middle"> <Flex gap="18px">
<Button <Button
type="primary" type="primary"
style={{ flexGrow: 1 }} style={{ flexGrow: 1 }}

View File

@ -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" style={{ padding: '0 15px'}}> <form id="kc-update-email-form" action={url.loginAction} method="post" style={{ padding: "0 15px" }}>
<div style={{ marginBottom: 15 }} className="kctbform"> <Flex style={{ marginBottom: "22px" }} vertical gap={"middle"}>
{/* 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}
@ -47,13 +47,11 @@ export default function UpdateEmail(props: UpdateEmailProps) {
onIsFormSubmittableValueChange={setIsFormSubmittable} onIsFormSubmittableValueChange={setIsFormSubmittable}
doMakeUserConfirmPassword={doMakeUserConfirmPassword} doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/> />
</div>
<div style={{ marginBottom: 24 }}>
<Checkbox id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true}> <Checkbox id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true}>
{msg("logoutOtherSessions")} {msg("logoutOtherSessions")}
</Checkbox> </Checkbox>
</div> </Flex>
<Flex gap={"middle"}> <Flex gap={"middle"}>
<Button <Button
style={{ flexGrow: 2 }} style={{ flexGrow: 2 }}

View File

@ -18,7 +18,17 @@ export const Default: Story = {
kcContext={{ kcContext={{
auth: { auth: {
attemptedUsername: "max.mustermann@mail.com", attemptedUsername: "max.mustermann@mail.com",
showUsername: true showUsername: true,
showTryAnotherWayLink: true
},
client: {
attributes: {
logoUri: "https://cdn.tombutcher.work/logos/grafana-logo.png",
policyUri: "https://twitter.com/en/tos",
tosUri: "https://twitter.com/en/privacy"
},
name: "Grafana",
clientId: "twitter-client-id"
} }
}} }}
/> />

View File

@ -60,12 +60,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
i18n={i18n} i18n={i18n}
doUseDefaultCss={false} doUseDefaultCss={false}
displayInfo={realm.registrationAllowed && !registrationDisabled} displayInfo={realm.registrationAllowed && !registrationDisabled}
infoNode={ infoNode={<Link href={url.registrationUrl}>{msg("doRegister")}</Link>}
<Space>
<Text>{msg("noAccount")}</Text>
<Link href={url.registrationUrl}>{msg("doRegister")}</Link>
</Space>
}
headerNode={msg("webauthn-login-title")} headerNode={msg("webauthn-login-title")}
> >
<div style={{ padding: "0 15px" }}> <div style={{ padding: "0 15px" }}>
@ -89,7 +84,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
{shouldDisplayAuthenticators && ( {shouldDisplayAuthenticators && (
<> <>
<List <List
style={{ marginBottom: "30px" }} style={{ marginBottom: "18px" }}
bordered bordered
dataSource={authenticators.authenticators as Authenticator[]} dataSource={authenticators.authenticators as Authenticator[]}
renderItem={(authenticator: Authenticator, i: number) => ( renderItem={(authenticator: Authenticator, i: number) => (
@ -136,12 +131,13 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
</> </>
)} )}
<Form.Item style={{ marginBottom: 0 }}> <Form.Item style={{ margin: 0 }}>
<Button <Button
id={authButtonId} id={authButtonId}
type="primary" type="primary"
size="large" size="large"
block block
style={{ marginTop: "4px" }}
autoFocus autoFocus
onClick={() => setIsLoading(true)} onClick={() => setIsLoading(true)}
disabled={isLoading} disabled={isLoading}

View File

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