Tom Butcher 9dccf4f107
Some checks failed
ci / Check if version upgrade (push) Has been cancelled
ci / create_github_release (push) Has been cancelled
ci / test (push) Has been cancelled
Adjusted padding for mobile view in FooterCard and refined button margin in LoginConfigTotp for improved layout consistency.
2025-08-14 01:14:42 +01:00

341 lines
16 KiB
TypeScript

import { useEffect, useState, useCallback } from "react";
import { useMediaQuery } from "react-responsive";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { TemplateProps } from "keycloakify/login/TemplateProps";
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, 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;
export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
displayInfo = false,
displayMessage = true,
headerNode,
infoNode = null,
documentTitle,
bodyClassName,
kcContext,
i18n,
doUseDefaultCss,
children
} = props;
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
const { realm, auth, url, message, isAppInitiatedAction, client } = kcContext;
const isMobile = useMediaQuery({ maxWidth: 600 });
const [darkMode, setDarkMode] = useState(false);
const [loading, setLoading] = useState(true);
const windowQuery = window.matchMedia("(prefers-color-scheme:dark)");
const darkModeChange = useCallback((event: MediaQueryListEvent) => {
console.log(event.matches ? true : false);
setDarkMode(event.matches ? true : false);
}, []);
useEffect(() => {
windowQuery.addEventListener("change", darkModeChange);
return () => {
windowQuery.removeEventListener("change", darkModeChange);
};
}, [windowQuery, darkModeChange]);
useEffect(() => {
setDarkMode(windowQuery.matches ? true : false);
document.fonts.ready.then(() => {
// Wait for all images
const images = Array.from(document.images);
if (images.length === 0) {
setLoading(false);
return;
}
let loaded = 0;
function checkDone() {
loaded++;
if (loaded === images.length) setLoading(false);
}
images.forEach(img => {
if (img.complete) {
checkDone();
} else {
img.addEventListener("load", checkDone);
img.addEventListener("error", checkDone);
}
});
});
}, []);
useEffect(() => {
document.title = documentTitle ?? msgStr("loginTitle", realm.displayName);
}, []);
useSetClassName({
qualifiedName: "body",
className: bodyClassName ?? ""
});
const { isReadyToRender } = useInitialize({ kcContext, doUseDefaultCss });
if (!isReadyToRender) {
return null;
}
// Language menu items for dropdown
const languageItems = {
items: enabledLanguages.map((language, index) => ({
key: `language-${index}`,
label: <a href={language.href}>{language.label}</a>
}))
};
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: darkMode ? "#D20294FF" : "rgba(212, 0, 255, 1)",
colorLink: "#6E00FF",
colorLinkHover: "#b175ff",
borderRadius: 20
},
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm
}}
>
<Layout style={{ minHeight: "var(--unit-100vh)", maxHeight: "var(--unit-100vh)" }}>
<ParticlesBackground darkMode={darkMode} />
{loading == true ? (
<div className="loadingOverlay" style={{ backgroundColor: darkMode ? "#000000" : "#ffffff" }}>
<Spin />
</div>
) : null}
<Content
style={{
background: "#f5f5f5"
}}
>
<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, marginTop: 10 }}
/>
) : (
<img
src="https://cdn.tombutcher.work/logos/logo-horizontal.svg"
alt="Logo"
style={{ height: "60px", padding: "0 30px", zIndex: 1, marginBottom: 10, marginTop: 10 }}
/>
)}
</>
)}
<Flex vertical gap={"middle"}>
<Card
style={{
background: darkMode ? "#00000025" : "#fffffff2",
borderRadius: isMobile ? "0px" : "20px",
width: isMobile ? "100vw" : "450px",
zIndex: 1,
height: isMobile ? "100vh" : "unset",
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={{
body: {
padding: 0,
height: "100%"
}
}}
>
<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: "60%", margin: "0", marginBottom: "4px" }}
/>{" "}
<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" }} />}
</>
)}
{showImutableUsername && (
<>
<Card
style={{ display: "flex", alignItems: "center", margin: 0 }}
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>
{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>
<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>
</Card>
{!isMobile && (
<Flex gap={"middle"} style={{ width: "100%" }} 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>
{!isMobile && (
<Flex style={{ zIndex: 1 }} gap={"large"}>
<Text style={{ color: "#ffffff", fontWeight: 700 }}>© 2025</Text>
{enabledLanguages.length > 1 && (
<>
<Text style={{ color: "#ffffff" }}>|</Text>
<Dropdown menu={languageItems} trigger={["hover"]}>
<Text style={{ color: "#ffffff", fontWeight: 700 }}>
<Space>
{currentLanguage.label}
<GlobalOutlined />
</Space>
</Text>
</Dropdown>
</>
)}
</Flex>
)}
</Flex>
</Content>
</Layout>
</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 ? "4px 0px" : "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>
);
}