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,20 +86,6 @@ export default function KcPage(props: { kcContext: KcContext }) {
);
case "register.ftl":
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={false}
classes={{}}
headerNode={
kcContext.messageHeader !== undefined
? i18n.advancedMsg(kcContext.messageHeader)
: i18n.msg("registerTitle")
}
displayMessage={kcContext.messagesPerField.exists(
"global"
)}
>
<Register
{...{ kcContext, i18n, classes }}
Template={Template}
@ -107,7 +93,6 @@ export default function KcPage(props: { kcContext: KcContext }) {
UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
</Template>
);
case "error.ftl":
@ -184,15 +169,6 @@ export default function KcPage(props: { kcContext: KcContext }) {
);
case "login-update-profile.ftl":
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={false}
headerNode={i18n.msg("loginProfileTitle")}
displayMessage={kcContext.messagesPerField.exists(
"global"
)}
>
<LoginUpdateProfile
{...{ kcContext, i18n, classes }}
Template={Template}
@ -200,7 +176,6 @@ export default function KcPage(props: { kcContext: KcContext }) {
UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
</Template>
);
case "login-oauth2-device-verify-user-code.ftl":
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 { 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);
// this should be run only once per application lifetime
@ -28,7 +32,85 @@ const ParticlesBackground = () => {
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: {
color: {
@ -119,11 +201,11 @@ const ParticlesBackground = () => {
<Particles
id="tsparticles"
particlesLoaded={particlesLoaded}
options={options}
options={darkMode ? darkOptions : lightOptions}
/>
<div
style={{
background: "rgba(255, 255, 255, 0.0)",
background: "rgba(0, 0, 0, 0.0)",
backdropFilter: "blur(50px)",
width: "100%",
height: "100%",

View File

@ -6,10 +6,10 @@ import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useInitialize } from "keycloakify/login/Template.useInitialize";
import type { I18n } from "./i18n";
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 ParticlesBackground from "./ParticlesBackground";
import type { ReactNode } from "react";
const { Content } = Layout;
const { Title, Text, Link } = Typography;
@ -17,7 +17,6 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
displayInfo = false,
displayMessage = true,
displayRequiredFields = false,
headerNode,
infoNode = null,
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 (
<ConfigProvider
theme={{
token: {
colorPrimary: "rgba(212, 0, 255, 1)",
colorPrimary: darkMode ? "#D20294FF" : "rgba(212, 0, 255, 1)",
colorLink: "#6E00FF",
colorLinkHover: "#b175ff",
borderRadius: 20
@ -111,7 +150,7 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}}
>
<Layout style={{ minHeight: "var(--unit-100vh)", maxHeight: "var(--unit-100vh)" }}>
<ParticlesBackground />
<ParticlesBackground darkMode={darkMode} />
{loading == true ? (
<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"}>
{!isMobile && (
<>
{darkMode ? (
<img
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: 5 }}
style={{ height: "60px", padding: "0 30px", zIndex: 1, marginBottom: 10 }}
/>
)}
</>
)}
<Card
style={{
background: darkMode ? "#00000025" : "#fffffff2",
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%)"
padding: "26px 15px 30px 15px",
boxShadow: darkMode ? "0px 5px 30px 5px rgb(200 200 200 / 5%)" : "0px 5px 15px 5px rgb(0 0 0 / 10%)"
}}
variant="borderless"
styles={{
@ -149,37 +199,32 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
}
}}
>
<Flex style={{ height: "100%" }} vertical>
<div style={{ height: "100%", margin: "0 15px" }}>
<Flex style={{ height: "100%" }} vertical className={darkMode ? "darkMode" : ""}>
{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: "70%", margin: "0" }}
style={{ width: "60%", margin: "0", marginBottom: "4px" }}
/>{" "}
<Divider style={{ margin: "24px 0" }} />
<Divider style={{ margin: "4px 0" }} />
</Flex>
)}
<Flex style={{ margin: "0 15px", paddingBottom: "18px" }} vertical gap={"middle"}>
{headerNode && (
<>
<Title level={2} style={{ marginBottom: "0", flexGrow: 1 }} className={darkMode ? "dark" : ""}>
{headerNode}
</Title>
{isMobile && <Divider style={{ margin: "4px 0" }} />}
</>
)}
<div style={{ marginBottom: "10px" }}>
<Flex gap={"large"} align="center" style={{ paddingBottom: "8px" }}>
<Title level={2} style={{ marginBottom: "0", flexGrow: 1 }}>
{headerNode}
</Title>
{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 ? (
{showImutableUsername && (
<>
<Card
style={{ display: "flex", alignItems: "center", marginBottom: "24px", marginTop: "8px" }}
style={{ display: "flex", alignItems: "center", margin: 0 }}
styles={{ body: { padding: "8px 12px" } }}
>
<Flex gap={"small"}>
@ -194,27 +239,12 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</Link>
</Flex>
</Card>
) : null;
if (displayRequiredFields) {
return (
<>
{node}
<div style={{ marginBottom: "12px" }}>
<Text type="secondary">
<span style={{ color: "#ff4d4f" }}>*</span> {msg("requiredFields")}
</Text>
</div>
{isMobile && <Divider style={{ margin: "4px 0" }} />}
</>
);
}
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) && (
{showMessage && (
<>
<Alert
message={<span dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />}
type={
@ -227,32 +257,37 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
: "info"
}
showIcon
style={{ marginBottom: "26px" }}
style={{ margin: 0, width: "100%" }}
/>
{isMobile && <Divider style={{ margin: "4px 0" }} />}
</>
)}
</div>
</Flex>
<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 && (
<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>
<FooterCard darkMode={darkMode}>{showTryAnotherWayLink}</FooterCard>
)}
{displayInfo && <div style={{ marginTop: "24px", textAlign: "center" }}>{infoNode}</div>}
{displayInfo && <FooterCard darkMode={darkMode}>{infoNode}</FooterCard>}
</Flex>
)}
</Flex>
</Flex>
</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 && (
<Flex style={{ zIndex: 1 }} gap={"large"}>
<Text style={{ color: "#ffffff", fontWeight: 700 }}>© 2025</Text>
@ -277,3 +312,27 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
</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 { KcContext } from "./KcContext";
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";
const { Text, Title } = Typography;
@ -36,29 +36,19 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
const groupNameRef = { current: "" };
return (
<>
<Flex vertical gap={"middle"}>
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
return (
<>
<GroupLabel attribute={attribute} groupNameRef={groupNameRef} i18n={i18n} />
<Flex gap={"5px"} vertical>
<Text>
{advancedMsg(attribute.displayName ?? "")}
<Text type="danger"> *</Text>
</Text>
<Form.Item
label={advancedMsg(attribute.displayName ?? "")}
style={{ margin: 0 }}
validateStatus={displayableErrors.length > 0 ? "error" : undefined}
help={
<>
{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>
)}
</>
}
rules={[{ required: true }]}
name={attribute.name}
shouldUpdate
@ -94,10 +84,24 @@ export default function UserProfileFormFields(props: UserProfileFormFieldsProps<
/>
)}
</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: linear-gradient(
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-text-fill-color: transparent;
@ -57,8 +69,8 @@ a.ant-typography,
background: rgb(110, 0, 255) !important;
background: linear-gradient(
45deg,
rgba(110, 0, 255, 1) 0%,
rgba(212, 0, 255, 1) 100%
rgb(170, 0, 255) 0%,
rgb(255, 0, 115) 100%
) !important ;
-webkit-background-clip: text !important;
-webkit-text-fill-color: transparent !important;
@ -192,15 +204,36 @@ a.ant-typography,
background: transparent !important;
}
/* Autofill fix for light mode */
@media (prefers-color-scheme: light) {
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active,
input:-internal-autofill-selected {
-webkit-background-clip: text;
-webkit-text-fill-color: #7e8500;
box-shadow: inset 0 0 20px 20px #ffffff !important;
background-color: green !important
input:-internal-autofill-selected,
input:-internal-autofill-previewed {
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"] {

View File

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

View File

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

View File

@ -2,7 +2,9 @@ import type { PageProps } from "keycloakify/login/pages/PageProps";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { KcContext } from "../KcContext";
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>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -18,16 +20,17 @@ export default function Error(props: PageProps<Extract<KcContext, { pageId: "err
displayMessage={false}
headerNode={msg("errorTitle")}
>
<div style={{ margin: "0 15px" }}>
<Space direction="vertical" size="middle" style={{ marginBottom: 24 }}>
<Alert message={<div dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />} type="error" showIcon />
</Space>
<Flex style={{ margin: "0 15px" }} vertical>
<Text type="danger">
<div dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />
</Text>
{!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")}
</Button>
)}
</div>
</Flex>
</Template>
);
}

View File

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

View File

@ -1,10 +1,10 @@
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 { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
const { Paragraph } = Typography;
const { Text } = Typography;
export default function FrontchannelLogout(props: PageProps<Extract<KcContext, { pageId: "frontchannel-logout.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
@ -26,8 +26,8 @@ export default function FrontchannelLogout(props: PageProps<Extract<KcContext, {
documentTitle={msgStr("frontchannel-logout.title")}
headerNode={msg("frontchannel-logout.title")}
>
<Space direction="vertical" style={{ width: "100%", padding: "0 15px" }}>
<Paragraph>{msg("frontchannel-logout.message")}</Paragraph>
<Flex vertical style={{ width: "100%", padding: "0 15px" }} gap={"middle"}>
<Text>{msg("frontchannel-logout.message")}</Text>
<List
dataSource={logout.clients}
@ -41,11 +41,11 @@ export default function FrontchannelLogout(props: PageProps<Extract<KcContext, {
/>
{logout.logoutRedirectUri && (
<Button type="primary" id="continue" href={logout.logoutRedirectUri}>
<Button type="primary" id="continue" href={logout.logoutRedirectUri} size="large">
{msg("doContinue")}
</Button>
)}
</Space>
</Flex>
</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 { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -29,7 +29,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
if (pageRedirectUri) {
return (
<Button type="primary" block size={"large"} href={pageRedirectUri} style={{ marginTop: 24 }}>
<Button type="primary" block size={"large"} href={pageRedirectUri}>
{msg("backToApplication")}
</Button>
);
@ -38,7 +38,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
if (actionUri) {
return (
<>
<Button type="primary" block size={"large"} href={actionUri} style={{ marginTop: 24 }}>
<Button type="primary" block size={"large"} href={actionUri}>
{msg("proceedWithAction")}
</Button>
</>
@ -48,7 +48,7 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
if (client.baseUrl) {
return (
<>
<Button type="primary" block size={"large"} href={client.baseUrl} style={{ marginTop: 24 }}>
<Button type="primary" block size={"large"} href={client.baseUrl}>
{msg("backToApplication")}
</Button>
</>
@ -61,13 +61,12 @@ export default function Info(props: PageProps<Extract<KcContext, { pageId: "info
return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} displayMessage={false} headerNode={messageHeader}>
<div style={{ padding: "0 15px" }}>
<Space direction="vertical" size="middle">
<Flex vertical gap="18px">
<Text className="instruction">
<span dangerouslySetInnerHTML={{ __html: getMessageContent() }}></span>
</Text>
</Space>
{renderActionLink()}
</Flex>
</div>
</Template>
);

View File

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

View File

@ -20,7 +20,16 @@ export const WithManualSetUp: Story = {
render: () => (
<KcPageStory
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 { getKcClsx, KcClsx } from "keycloakify/login/lib/kcClsx";
// Removed unused kcClsx utilities
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
@ -17,10 +17,7 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
"cancel-aia": string;
};
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
// kcClsx no longer needed after layout changes
const [isSubmitLoading, setIsSubmitLoading] = 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>}
displayMessage={!messagesPerField.existsError("totp", "userLabel")}
>
<Form layout="vertical" onFinish={handleSubmit} style={{ margin: "0 auto" }}>
<div style={{ overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px", marginBottom: 24 }}>
<Form
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
direction="vertical"
current={3}
@ -184,73 +201,67 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
}
]}
/>
<Divider />
<Divider style={{ marginTop: "12px" }} />
<Flex vertical gap={"middle"}>
<Flex gap="small" vertical>
<Text>
{msg("authenticatorCode")} <Text type="danger">*</Text>
</Text>
<Flex gap="small" align="center">
<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />
<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"))
}}
/>
)
}
style={{ margin: 0 }}
>
<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")}
/>
<Input.OTP id="totp" size="large" aria-invalid={messagesPerField.existsError("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
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"))
}}
/>
)
}
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" }} />}
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"}>
{kcSanitize(messagesPerField.get("userLabel"))}
</Text>
)}
</Flex>
</Flex>
</div>
<div style={{ padding: "0 15px" }}>
<Form.Item>
<LogoutOtherSessions kcClsx={kcClsx} i18n={i18n} />
<Form.Item style={{ margin: 0 }}>
<Checkbox id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true}>
{msg("logoutOtherSessions")}
</Checkbox>
</Form.Item>
<Form.Item>
<Form.Item style={{ margin: 0 }}>
<Flex gap={"middle"}>
<Button
type="primary"
htmlType="submit"
style={{ flexGrow: 2 }}
style={{ flexGrow: 2, marginTop: "22px" }}
size="large"
id="saveTOTPBtn"
iconPosition="end"
@ -287,14 +298,3 @@ export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pa
</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 { Button, Typography, Form, Input } from "antd";
import { Button, Typography, Form, Input, Flex } from "antd";
import { PageProps } from "keycloakify/login/pages/PageProps";
import { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -44,19 +44,15 @@ export default function LoginOauth2DeviceVerifyUserCode(
return (
<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" }}>
<Flex gap="18px" vertical>
<Text>{msg("verifyOAuth2DeviceUserCode")}</Text>
<Form.Item label={"Code"} name="device_user_code" rules={[{ required: true, message: "Required" }]}>
<Input
id="device-user-code"
name="device_user_code"
style={{ marginBottom: "10px" }}
autoComplete="off"
autoFocus
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />}
/>
<Flex gap="small" align="center" style={{ marginBottom: "10px" }}>
<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />
<Form.Item name="device_user_code" rules={[{ required: true, message: "Required" }]}>
<Input.OTP id="device-user-code" autoFocus size="large" />
</Form.Item>
</Flex>
<Form.Item>
<Button
@ -78,6 +74,7 @@ export default function LoginOauth2DeviceVerifyUserCode(
{msgStr("doSubmit")}
</Button>
</Form.Item>
</Flex>
</Form>
</Template>
);

View File

@ -16,8 +16,8 @@ const mockKcContext = {
policyUri: "https://twitter.com/en/tos",
tosUri: "https://twitter.com/en/privacy"
},
name: "Twitter",
clientId: "twitter-client-id"
name: "Grafana",
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)}
>
<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) && (
<Alert
message={
client.name ? msg("oauthGrantInformation", advancedMsgStr(client.name)) : msg("oauthGrantInformation", client.clientId)
}
type="info"
style={{ marginBottom: "20px" }}
style={{ marginBottom: "18px" }}
showIcon
></Alert>
)}
@ -84,11 +84,12 @@ export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pa
</Space>
</List.Item>
)}
style={{ marginBottom: "18px" }}
/>
)}
{(client.attributes.policyUri || client.attributes.tosUri) && (
<div style={{ marginTop: 10 }}>
<Space direction="vertical" style={{ marginTop: "12px" }}>
<div style={{ marginBottom: "22px" }}>
<Space direction="vertical">
{client.attributes.tosUri && (
<Text>
{msg("oauthGrantReview")}{" "}
@ -108,7 +109,7 @@ export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pa
</Space>
</div>
)}
<Flex gap="middle" style={{ marginTop: 24 }}>
<Flex gap="18px">
<Button
type="primary"
style={{ flexGrow: 1 }}

View File

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

View File

@ -93,7 +93,7 @@ export default function LoginPassword(props: PageProps<Extract<KcContext, { page
disabled={isLoading}
loading={isLoading}
iconPosition="end"
style={{ marginTop: 24 }}
style={{ marginTop: 27 }}
icon={
<img
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" }}>
<Space direction="vertical" size="middle">
<Space direction="vertical" size="large">
<Alert message={msg("recovery-code-config-warning-title")} type="warning" showIcon />
<Flex vertical>
<Alert message={msg("recovery-code-config-warning-title")} type="warning" showIcon style={{ marginBottom: "18px" }} />
<Text>{msg("recovery-code-config-warning-message")}</Text>
</Space>
<Flex id={olRecoveryCodesListId} gap="middle" wrap>
</Flex>
<Flex id={olRecoveryCodesListId} gap="18px" wrap>
{recoveryAuthnCodesConfigBean.generatedRecoveryAuthnCodesList.map((code, index) => (
<Text key={index} strong code style={{ fontSize: "18px" }}>
{formatRecoveryCode(code)}
@ -58,16 +59,6 @@ export default function LoginRecoveryAuthnCodeConfig(props: PageProps<Extract<Kc
</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"
@ -77,9 +68,20 @@ export default function LoginRecoveryAuthnCodeConfig(props: PageProps<Extract<Kc
<Input type="hidden" name="generatedAt" value={recoveryAuthnCodesConfigBean.generatedAt} />
<Input type="hidden" id="userLabel" name="userLabel" value={msgStr("recovery-codes-label-default")} />
<LogoutOtherSessions i18n={i18n} />
<Checkbox
id="kcRecoveryCodesConfirmationCheck"
name="kcRecoveryCodesConfirmationCheck"
onChange={e => setIsConfirmed(e.target.checked)}
style={{ marginBottom: "9px" }}
>
{msg("recovery-codes-confirmation-message")}
</Checkbox>
<div style={{ marginTop: 24 }}>
<Checkbox id="logout-sessions" name="logout-sessions" value="on" defaultChecked style={{ marginBottom: "22px" }}>
{msg("logoutOtherSessions")}
</Checkbox>
<div>
{isAppInitiatedAction ? (
<Space>
<Button type="primary" size="large" id="saveRecoveryAuthnCodesBtn" htmlType="submit" disabled={!isConfirmed}>
@ -96,20 +98,8 @@ export default function LoginRecoveryAuthnCodeConfig(props: PageProps<Extract<Kc
)}
</div>
</Form>
</Space>
</div>
</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
label={msg("auth-recovery-code-prompt", `${recoveryAuthnCodesInputBean.codeNumber}`)}
validateStatus={hasError ? "error" : ""}
style={{ margin: 0 }}
help={
hasError ? (
<span
@ -48,7 +49,7 @@ export default function LoginRecoveryAuthnCodeInput(props: PageProps<Extract<KcC
</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")}
</Button>
</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 { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -20,40 +20,38 @@ export default function LoginResetOtp(props: PageProps<Extract<KcContext, { page
headerNode={msg("doLogIn")}
>
<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%" }}>
<Flex vertical gap={"middle"} style={{ marginBottom: "22px" }}>
<Text id="kc-otp-reset-form-description">{msg("otp-reset-description")}</Text>
<Form.Item name="selectedCredentialId">
<Radio.Group defaultValue={configuredOtpCredentials.selectedCredentialId || undefined} style={{ width: "100%" }}>
{configuredOtpCredentials.userOtpCredentials.map((otpCredential, index) => (
<Radio
key={otpCredential.id}
value={otpCredential.id}
id={`kc-otp-credential-${index}`}
style={{
display: "block",
margin: "8px 0"
}}
>
<Space style={{ paddingBottom: "10px" }}>
<Radio.Group defaultValue={configuredOtpCredentials.selectedCredentialId} style={{ width: "100%" }}>
<List
dataSource={configuredOtpCredentials.userOtpCredentials}
bordered
style={{ width: "100%", lineHeight: 1 }}
renderItem={(otpCredential, index) => (
<List.Item key={index}>
<Flex align="center" gap={"small"} justify="space-between">
<Radio id={`kc-otp-credential-${index}`} value={otpCredential.id} style={{ display: "block" }}>
<Text style={{ position: "relative", top: "-3px" }}>{otpCredential.userLabel}</Text>
</Radio>
<img
src={"https://cdn.tombutcher.work/icons/auth/c-key.svg"}
width={14}
style={{ marginRight: "3px", marginBottom: "5px" }}
style={{ marginRight: "3px", height: "16px" }}
/>
</Flex>
</List.Item>
)}
/>
<Text>{otpCredential.userLabel}</Text>
</Space>
</Radio>
))}
</Radio.Group>
</Form.Item>
</Flex>
<Form.Item>
<Button id="kc-otp-reset-form-submit" type="primary" htmlType="submit" block size="large">
{msgStr("doSubmit")}
</Button>
</Form.Item>
</Space>
</Flex>
</Form>
</Template>
);

View File

@ -1,5 +1,5 @@
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 { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
@ -43,7 +43,8 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={false}
infoNode={false}
infoNode={<Link href={url.loginUrl}>{msg("backToLogin")}</Link>}
displayInfo={true}
displayMessage={!messagesPerField.existsError("username")}
headerNode={msg("emailForgotTitle")}
>
@ -57,7 +58,7 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
method="post"
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
label={inputLabel}
@ -84,15 +85,11 @@ export default function LoginResetPassword(props: PageProps<Extract<KcContext, {
/>
</Form.Item>
<Form.Item style={{ marginBottom: 22, marginTop: 26 }}>
<Form.Item style={{ marginBottom: 22, marginTop: "22px" }}>
<Button type="primary" htmlType="submit" size="large" block>
{msgStr("doSubmit")}
</Button>
</Form.Item>
<Space style={{ width: "100%", justifyContent: "center" }}>
<Link href={url.loginUrl}>{msg("backToLogin")}</Link>
</Space>
</Form>
</Template>
);

View File

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

View File

@ -1,11 +1,13 @@
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 { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
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> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
@ -16,6 +18,7 @@ export default function LoginUpdateProfile(props: LoginUpdateProfileProps) {
const { kcContext, i18n, UserProfileFormFields, doMakeUserConfirmPassword } = props;
const { url, isAppInitiatedAction } = kcContext;
const { msg, msgStr } = i18n;
const isMobile = useMediaQuery({ maxWidth: 600 });
const [form] = Form.useForm();
@ -59,7 +62,15 @@ export default function LoginUpdateProfile(props: LoginUpdateProfileProps) {
};
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={false}
headerNode={i18n.msg("loginProfileTitle")}
displayMessage={kcContext.messagesPerField.exists("global")}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<div style={{ overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px" }}>
<UserProfileFormFields
kcContext={kcContext}
i18n={i18n}
@ -67,10 +78,10 @@ export default function LoginUpdateProfile(props: LoginUpdateProfileProps) {
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
onIsFormSubmittableValueChange={() => {}}
/>
<Divider />
<Form.Item>
<Flex gap="middle">
</div>
<div style={{ padding: "0 15px" }}>
<Form.Item style={{ marginTop: 22 }}>
<Flex gap="18px">
<Button
type="primary"
size="large"
@ -104,6 +115,8 @@ export default function LoginUpdateProfile(props: LoginUpdateProfileProps) {
)}
</Flex>
</Form.Item>
</div>
</Form>
</Template>
);
}

View File

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

View File

@ -66,12 +66,7 @@ export default function LoginUsername(props: PageProps<Extract<KcContext, { page
doUseDefaultCss={false}
headerNode={msg("loginAccountTitle")}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
infoNode={
<Space>
<Text>{msg("noAccount")}</Text>
<Link href={url.registrationUrl}>{msg("doRegister")}</Link>
</Space>
}
infoNode={<Link href={url.registrationUrl}>{msg("doRegister")}</Link>}
socialProvidersNode={
<>
{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 (
<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 style={{ marginBottom: "25px" }}>
<Text className="instruction">{msg("logoutConfirmHeader")}</Text>
@ -64,11 +72,6 @@ export default function LogoutConfirm(props: PageProps<Extract<KcContext, { page
>
{msgStr("doLogout")}
</Button>
{!logoutConfirm.skipLink && client.baseUrl && (
<div style={{ marginTop: 16, textAlign: "center" }}>
<Link href={client.baseUrl}>{msg("backToApplication")}</Link>
</div>
)}
</div>
</Template>
);

View File

@ -1,5 +1,5 @@
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 { LazyOrNot } from "keycloakify/tools/LazyOrNot";
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 { I18n } from "../i18n";
import { useMediaQuery } from "react-responsive";
import Template from "../Template";
const { Title, Text, Link } = Typography;
@ -65,6 +66,16 @@ export default function Register(props: RegisterProps) {
};
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={false}
classes={{}}
headerNode={kcContext.messageHeader !== undefined ? i18n.advancedMsg(kcContext.messageHeader) : i18n.msg("registerTitle")}
displayMessage={kcContext.messagesPerField.exists("global")}
displayInfo={true}
infoNode={<Link href={url.loginUrl}>{msg("backToLogin")}</Link>}
>
<Form form={form} layout="vertical" name="registerForm" requiredMark onFinish={onFinish}>
<div style={{ overflowY: "scroll", maxHeight: isMobile ? "100%" : "250px", padding: "0 15px" }}>
<UserProfileFormFields
@ -91,13 +102,13 @@ export default function Register(props: RegisterProps) {
<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%" }}>
<Form.Item style={{ marginTop: 22 }}>
<Flex style={{ width: "100%" }} gap={"middle"}>
{recaptchaRequired && !recaptchaVisible && recaptchaAction !== undefined ? (
<Button
type="primary"
htmlType="submit"
block
style={{ flexGrow: 1 }}
size="large"
disabled={isLoading}
loading={isLoading}
@ -114,7 +125,7 @@ export default function Register(props: RegisterProps) {
<Button
type="primary"
htmlType="submit"
block
style={{ flexGrow: 1 }}
size="large"
loading={isLoading}
disabled={!submittable || isLoading || (termsAcceptanceRequired && !areTermsAccepted)}
@ -122,14 +133,11 @@ export default function Register(props: RegisterProps) {
{msgStr("doRegister")}
</Button>
)}
<div style={{ marginTop: 16, textAlign: "center" }}>
<Link href={url.loginUrl}>{msg("backToLogin")}</Link>
</div>
</Space>
</Flex>
</Form.Item>
</div>
</Form>
</Template>
);
}

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 { KcContext } from "../KcContext";
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>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { url, auth, client } = kcContext;
const { url, auth } = kcContext;
const { msg, advancedMsg } = i18n;
return (
@ -17,24 +17,9 @@ export default function SelectAuthenticator(props: PageProps<Extract<KcContext,
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayInfo={false}
headerNode={
<>
{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>
)}
</>
}
headerNode={msg("loginChooseAuthenticator")}
>
<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
dataSource={auth.authenticationSelections}
itemLayout="horizontal"
@ -42,8 +27,7 @@ export default function SelectAuthenticator(props: PageProps<Extract<KcContext,
renderItem={authenticationSelection => (
<List.Item
style={{
padding: 0,
padding: 0
}}
>
<Button

View File

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

View File

@ -37,8 +37,8 @@ export default function UpdateEmail(props: UpdateEmailProps) {
displayMessage={messagesPerField.exists("global")}
headerNode={msg("updateEmailTitle")}
>
<form id="kc-update-email-form" action={url.loginAction} method="post" style={{ padding: '0 15px'}}>
<div style={{ marginBottom: 15 }} className="kctbform">
<form id="kc-update-email-form" action={url.loginAction} method="post" style={{ padding: "0 15px" }}>
<Flex style={{ marginBottom: "22px" }} vertical gap={"middle"}>
{/* Keep original UserProfileFormFields component, but wrap in Ant Design styling */}
<UserProfileFormFields
kcContext={kcContext}
@ -47,13 +47,11 @@ export default function UpdateEmail(props: UpdateEmailProps) {
onIsFormSubmittableValueChange={setIsFormSubmittable}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
</div>
<div style={{ marginBottom: 24 }}>
<Checkbox id="logout-sessions" name="logout-sessions" value="on" defaultChecked={true}>
{msg("logoutOtherSessions")}
</Checkbox>
</div>
</Flex>
<Flex gap={"middle"}>
<Button
style={{ flexGrow: 2 }}

View File

@ -18,7 +18,17 @@ export const Default: Story = {
kcContext={{
auth: {
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}
doUseDefaultCss={false}
displayInfo={realm.registrationAllowed && !registrationDisabled}
infoNode={
<Space>
<Text>{msg("noAccount")}</Text>
<Link href={url.registrationUrl}>{msg("doRegister")}</Link>
</Space>
}
infoNode={<Link href={url.registrationUrl}>{msg("doRegister")}</Link>}
headerNode={msg("webauthn-login-title")}
>
<div style={{ padding: "0 15px" }}>
@ -89,7 +84,7 @@ export default function WebauthnAuthenticate(props: PageProps<Extract<KcContext,
{shouldDisplayAuthenticators && (
<>
<List
style={{ marginBottom: "30px" }}
style={{ marginBottom: "18px" }}
bordered
dataSource={authenticators.authenticators as Authenticator[]}
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
id={authButtonId}
type="primary"
size="large"
block
style={{ marginTop: "4px" }}
autoFocus
onClick={() => setIsLoading(true)}
disabled={isLoading}

View File

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