341 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|