Minor Fixes

This commit is contained in:
Tom Butcher 2025-04-07 00:16:14 +01:00
parent ccf4db5516
commit 8dec6b6ca3
134 changed files with 22520 additions and 3945 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -3,8 +3,11 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="https://cdn.tombutcher.work/favicon/favicon-auth.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" /> <link
rel="apple-touch-icon"
href="https://cdn.tombutcher.work/favicon/favicon-auth192.png"
/>
</head> </head>
<body> <body>

7853
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "keycloakify-starter", "name": "auth-tombutcher-work",
"version": "0.0.0", "version": "0.0.0",
"description": "Starter for Keycloakify 11", "description": "auth.tombutcher.work Theme",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/codegouvfr/keycloakify-starter.git" "url": "git://github.com/codegouvfr/keycloakify-starter.git"
@ -17,15 +17,21 @@
"license": "MIT", "license": "MIT",
"keywords": [], "keywords": [],
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1",
"@tsparticles/engine": "^3.8.1",
"@tsparticles/react": "^3.0.0",
"@tsparticles/slim": "^3.8.1",
"antd": "^5.24.3",
"keycloakify": "^11.8.17", "keycloakify": "^11.8.17",
"react": "^18.2.0", "react": "^19.0.0",
"react-dom": "^18.2.0" "react-dom": "^19.0.0",
"react-icons": "^5.5.0",
"react-responsive": "^10.0.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.15.0", "@eslint/js": "^9.15.0",
"storybook": "^8.1.10", "@storybook/react": "^8.6.6",
"@storybook/react": "^8.1.10", "@storybook/react-vite": "^8.6.6",
"@storybook/react-vite": "^8.1.10",
"@types/react": "^18.2.43", "@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17", "@types/react-dom": "^18.2.17",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
@ -39,6 +45,7 @@
"eslint-plugin-storybook": "^0.11.1", "eslint-plugin-storybook": "^0.11.1",
"globals": "^15.12.0", "globals": "^15.12.0",
"prettier": "3.3.1", "prettier": "3.3.1",
"storybook": "^8.6.6",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"typescript-eslint": "^8.15.0", "typescript-eslint": "^8.15.0",
"vite": "^5.0.8" "vite": "^5.0.8"

12
public/default.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 23.569 23.2312">
<g>
<rect height="23.2312" opacity="0" width="23.569" x="0" y="0"/>
<path d="M1.14421 7.83303C1.89608 7.83303 2.29592 7.41186 2.29592 6.66749L2.29592 4.01999C2.29592 2.88139 2.88772 2.31936 3.97733 2.31936L6.6846 2.31936C7.44069 2.31936 7.85014 1.9078 7.85014 1.16554C7.85014 0.423278 7.44069 0.0234375 6.6846 0.0234375L3.84421 0.0234375C1.31484 0.0234375 0 1.31695 0 3.8196L0 6.66749C0 7.41397 0.41156 7.83303 1.14421 7.83303ZM22.0657 7.83303C22.8176 7.83303 23.2174 7.41397 23.2174 6.66749L23.2174 3.8196C23.2174 1.31695 21.9047 0.0234375 19.3732 0.0234375L16.5232 0.0234375C15.7788 0.0234375 15.3673 0.423278 15.3673 1.16554C15.3673 1.9078 15.7809 2.31936 16.5232 2.31936L19.2305 2.31936C20.3105 2.31936 20.9215 2.88139 20.9215 4.01999L20.9215 6.66749C20.9215 7.41397 21.333 7.83303 22.0657 7.83303ZM3.84421 23.2312L6.6846 23.2312C7.44069 23.2312 7.85014 22.8197 7.85014 22.087C7.85014 21.3448 7.43647 20.9353 6.6846 20.9353L3.97733 20.9353C2.88772 20.9353 2.29592 20.3733 2.29592 19.2326L2.29592 16.5851C2.29592 15.8311 1.88647 15.4195 1.14421 15.4195C0.399841 15.4195 0 15.8311 0 16.5851L0 19.4255C0 21.9356 1.31484 23.2312 3.84421 23.2312ZM16.5232 23.2312L19.3732 23.2312C21.9047 23.2312 23.2174 21.926 23.2174 19.4255L23.2174 16.5851C23.2174 15.8311 22.808 15.4195 22.0657 15.4195C21.3234 15.4195 20.9215 15.8332 20.9215 16.5851L20.9215 19.2326C20.9215 20.3733 20.3105 20.9353 19.2305 20.9353L16.5232 20.9353C15.7788 20.9353 15.3673 21.3448 15.3673 22.087C15.3673 22.8197 15.7788 23.2312 16.5232 23.2312Z" fill="#5e5ce6" fill-opacity="0.5"/>
<path d="M11.6173 4.61436C9.45984 4.61436 7.7414 6.34241 7.7414 8.4764C7.7414 10.0875 8.68664 11.4886 10.1487 12.0809L10.1487 17.869C10.1583 18.0382 10.2253 18.1828 10.3552 18.3084L11.3672 19.309C11.5172 19.4473 11.7255 19.459 11.8798 19.3027L13.7606 17.4314C13.9329 17.2687 13.9244 17.0327 13.7585 16.8818L12.6666 15.7814L14.2029 14.2643C14.3517 14.123 14.3442 13.8827 14.1837 13.7105L12.6919 12.2091C14.4816 11.4569 15.476 10.1037 15.476 8.4764C15.476 6.35202 13.7459 4.61436 11.6173 4.61436ZM11.6077 6.47202C12.2512 6.47202 12.7744 7.00053 12.7744 7.64835C12.7744 8.30905 12.2512 8.84179 11.6077 8.84179C10.9662 8.84179 10.4238 8.30905 10.4238 7.64835C10.4238 7.00053 10.9566 6.47202 11.6077 6.47202Z" fill="#5e5ce6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

11
public/eye.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 19.262 11.8418">
<g>
<rect height="11.8418" opacity="0" width="19.262" x="0" y="0"/>
<path d="M9.39394 11.8418C14.9986 11.8418 18.7835 7.32375 18.7835 5.92552C18.7835 4.52416 14.993 0.0117576 9.39394 0.0117576C3.87065 0.0117576 0 4.52416 0 5.92552C0 7.32375 3.86504 11.8418 9.39394 11.8418ZM9.39394 10.5304C5.02072 10.5304 1.54819 6.87832 1.54819 5.92552C1.54819 5.12681 5.02072 1.32315 9.39394 1.32315C13.7504 1.32315 17.2353 5.12681 17.2353 5.92552C17.2353 6.87832 13.7504 10.5304 9.39394 10.5304ZM9.39517 9.60285C11.4368 9.60285 13.0849 7.94733 13.0849 5.91308C13.0849 3.87392 11.4368 2.22577 9.39517 2.22577C7.35478 2.22577 5.6998 3.87269 5.6998 5.91308C5.6998 7.94733 7.35478 9.60285 9.39517 9.60285ZM9.39271 7.11389C8.73414 7.11389 8.20367 6.5785 8.20367 5.91992C8.20367 5.26134 8.73414 4.72841 9.39271 4.72841C10.055 4.72841 10.5867 5.26134 10.5867 5.91992C10.5867 6.5785 10.055 7.11389 9.39271 7.11389Z" fill="#98989d"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/fonts/grold.woff2 Normal file

Binary file not shown.

Binary file not shown.

12
public/iphone.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 16.2518 25.6631">
<g>
<rect height="25.6631" opacity="0" width="16.2518" x="0" y="0"/>
<path d="M3.76475 23.2699C2.82772 23.2699 2.29592 22.7658 2.29592 21.8726L2.29592 3.75866C2.29592 2.86545 2.82772 2.36131 3.76475 2.36131L12.1472 2.36131C13.0725 2.36131 13.6043 2.86967 13.6043 3.76076L13.6043 21.8705C13.6043 22.7615 13.0725 23.2699 12.1472 23.2699Z" fill="#5e5ce6" fill-opacity="0.25"/>
<path d="M3.37101 25.6312L12.5798 25.6312C14.5701 25.6312 15.9002 24.3365 15.9002 22.3987L15.9002 3.23249C15.9002 1.29258 14.5701 0 12.5798 0L3.37101 0C1.35117 0 0 1.29258 0 3.23038L0 22.4008C0 24.3365 1.35117 25.6312 3.37101 25.6312ZM3.76475 23.2699C2.82772 23.2699 2.29592 22.7658 2.29592 21.8726L2.29592 3.75866C2.29592 2.86545 2.82772 2.36131 3.76475 2.36131L12.1472 2.36131C13.0725 2.36131 13.6043 2.86967 13.6043 3.76076L13.6043 21.8705C13.6043 22.7615 13.0725 23.2699 12.1472 23.2699ZM5.43045 22.3575L10.489 22.3575C10.8307 22.3575 11.0723 22.1201 11.0723 21.7645C11.0723 21.4069 10.8307 21.1769 10.489 21.1769L5.43045 21.1769C5.08874 21.1769 4.83538 21.4069 4.83538 21.7645C4.83538 22.1201 5.08874 22.3575 5.43045 22.3575ZM6.48209 5.02919L9.42772 5.02919C9.93115 5.02919 10.3219 4.63849 10.3219 4.12544C10.3219 3.62201 9.92904 3.2292 9.42772 3.2292L6.48209 3.2292C5.97116 3.2292 5.57835 3.62201 5.57835 4.12544C5.57835 4.63849 5.96905 5.02919 6.48209 5.02919Z" fill="#5e5ce6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

11
public/key.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 9.75228 17.2569">
<g>
<rect height="17.2569" opacity="0" width="9.75228" x="0" y="0"/>
<path d="M4.11564 16.9569C4.43228 17.222 4.86007 17.2562 5.15935 16.9638L7.27043 14.8515C7.5641 14.5534 7.55042 14.1014 7.26359 13.8078L6.25324 12.8018L7.75017 11.3105C8.04015 11.0236 8.0314 10.5648 7.74457 10.2668L6.37861 8.89519C8.23265 7.97685 9.27376 6.43945 9.27376 4.62601C9.27376 2.06664 7.20152 0 4.63968 0C2.05857 0 0 2.0542 0 4.62601C0 6.46789 1.05027 8.10838 2.71387 8.85951L2.71387 15.277C2.71387 15.5185 2.78961 15.7992 3.00016 15.9856ZM4.63968 15.7501L3.89443 15.0117L3.89443 7.96811C2.33351 7.62672 1.21679 6.25625 1.21679 4.62601C1.21679 2.74394 2.74394 1.22363 4.63968 1.22363C6.52982 1.22363 8.04891 2.74394 8.04891 4.62601C8.04891 6.24381 6.92658 7.62795 5.2206 8.0098L5.2206 9.48171L6.52846 10.7989L5.12846 12.1784L5.12846 13.4116L6.05117 14.3194ZM4.63968 4.44431C5.24685 4.44431 5.74465 3.94529 5.74465 3.33375C5.74465 2.72535 5.24685 2.23439 4.63968 2.23439C4.02131 2.23439 3.53472 2.72412 3.53472 3.33375C3.53472 3.94529 4.02691 4.44431 4.63968 4.44431Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

11
public/lock.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 10.0446 13.9699">
<g>
<rect height="13.9699" opacity="0" width="10.0446" x="0" y="0"/>
<path d="M1.58238 13.6144L7.98368 13.6144C9.02179 13.6144 9.56606 13.0577 9.56606 11.937L9.56606 7.09543C9.56606 5.98404 9.02179 5.42733 7.98368 5.42733L1.58238 5.42733C0.543046 5.42733 0 5.98404 0 7.09543L0 11.937C0 13.0577 0.543046 13.6144 1.58238 13.6144ZM1.77652 12.389C1.49241 12.389 1.33013 12.2155 1.33013 11.8929L1.33013 7.14204C1.33013 6.81816 1.49241 6.65273 1.77652 6.65273L7.79078 6.65273C8.08049 6.65273 8.23471 6.81816 8.23471 7.14204L8.23471 11.8929C8.23471 12.2155 8.08049 12.389 7.79078 12.389ZM1.25043 6.00168L2.53052 6.00168L2.53052 3.70166C2.53052 2.09644 3.55728 1.22417 4.78023 1.22417C6.00072 1.22417 7.04238 2.09644 7.04238 3.70166L7.04238 6.00168L8.31687 6.00168L8.31687 3.82307C8.31687 1.30074 6.64753 0 4.78023 0C2.91853 0 1.25043 1.30074 1.25043 3.82307Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

11
public/lock2.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 11.3389 15.9656">
<g>
<rect height="15.9656" opacity="0" width="11.3389" x="0" y="0"/>
<path d="M1.80844 15.5594L9.12421 15.5594C10.3106 15.5594 10.9326 14.9231 10.9326 13.6423L10.9326 8.10906C10.9326 6.83891 10.3106 6.20266 9.12421 6.20266L1.80844 6.20266C0.620625 6.20266 0 6.83891 0 8.10906L0 13.6423C0 14.9231 0.620625 15.5594 1.80844 15.5594ZM2.0303 14.1589C1.70562 14.1589 1.52015 13.9606 1.52015 13.5919L1.52015 8.16234C1.52015 7.79218 1.70562 7.60312 2.0303 7.60312L8.90375 7.60312C9.23484 7.60312 9.41109 7.79218 9.41109 8.16234L9.41109 13.5919C9.41109 13.9606 9.23484 14.1589 8.90375 14.1589ZM1.42906 6.85906L2.89202 6.85906L2.89202 4.23047C2.89202 2.39593 4.06546 1.39905 5.46312 1.39905C6.85797 1.39905 8.04844 2.39593 8.04844 4.23047L8.04844 6.85906L9.50499 6.85906L9.50499 4.36922C9.50499 1.48656 7.59718 0 5.46312 0C3.33547 0 1.42906 1.48656 1.42906 4.36922Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

12
public/passkey2.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32.5814 30.6542">
<g>
<rect height="30.6542" opacity="0" width="32.5814" x="0" y="0"/>
<path d="M21.5071 18.3514C21.5716 20.1403 22.4633 21.7234 23.8807 22.701L23.8807 25.9112L8.31866 25.9112C7.00147 25.9112 6.2135 25.2873 6.2135 24.2514C6.2135 21.2364 10.0387 17.0898 16.109 17.0898C18.199 17.0898 20.0223 17.5795 21.5071 18.3514ZM20.8462 9.80192C20.8462 12.7379 18.6729 15.009 16.1187 15.009C13.5569 15.009 11.3932 12.7379 11.3932 9.82325C11.3932 6.96059 13.5708 4.727 16.1187 4.727C18.6665 4.727 20.8462 6.92216 20.8462 9.80192Z" fill="#5e5ce6" fill-opacity="0.5"/>
<path d="M27.0447 14.3176C24.9082 14.3176 23.2137 16.0356 23.2137 18.1349C23.2137 19.7413 24.1448 21.11 25.5862 21.6979L25.5862 27.4143C25.5862 27.5781 25.665 27.7173 25.7798 27.8493L26.7937 28.84C26.9405 28.9804 27.1572 28.9963 27.3199 28.8336L29.179 26.9842C29.3383 26.8152 29.3299 26.5857 29.1769 26.4285L28.1032 25.3464L29.6179 23.8541C29.7731 23.7064 29.7752 23.4674 29.5987 23.2846L28.1381 21.8357C29.8879 21.0763 30.8683 19.7554 30.8683 18.1349C30.8683 16.0356 29.1621 14.3176 27.0447 14.3176ZM27.0351 16.0764C27.6722 16.0764 28.1941 16.5983 28.1941 17.2396C28.1941 17.8895 27.6722 18.4157 27.0351 18.4157C26.4002 18.4157 25.8687 17.8895 25.8687 17.2396C25.8687 16.5983 26.3906 16.0764 27.0351 16.0764Z" fill="#5e5ce6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

15
public/pencil.svg Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 39.3163 27.8386">
<g>
<rect height="27.8386" opacity="0" width="39.3163" x="0" y="0"/>
<path d="M24.843 10.0397L5.28206 10.0397C4.36002 10.0397 3.83877 10.5567 3.83877 11.4809L3.83877 21.2583C3.83877 22.1719 4.36002 22.6995 5.28206 22.6995L28.9387 22.6995C29.8607 22.6995 30.3841 22.1719 30.3841 21.2583L30.3841 13.3841L32.6969 11.0698C32.7036 11.1454 32.7053 11.2254 32.7053 11.3068L32.7053 21.4345C32.7053 23.6494 31.4772 24.8391 29.256 24.8391L4.97645 24.8391C2.75316 24.8391 1.51754 23.6494 1.51754 21.4345L1.51754 11.3068C1.51754 9.08769 2.75316 7.90222 4.97645 7.90222L26.9788 7.90222Z" fill="#5e5ce6" fill-opacity="0.5"/>
<path d="M22.7723 16.9573L25.2355 15.9005L35.3446 5.80103L33.5289 3.99284L23.4273 14.0944L22.3129 16.4958C22.1981 16.7625 22.4957 17.0754 22.7723 16.9573ZM36.2392 4.89471L37.0886 4.03783C37.559 3.55572 37.5674 2.90346 37.113 2.46284L36.7959 2.14784C36.385 1.74331 35.7168 1.79534 35.272 2.24018L34.431 3.07151Z" fill="#5e5ce6"/>
<path d="M18.5315 18.106C19.4931 18.106 20.2748 17.3264 20.2748 16.3648C20.2748 15.4032 19.4931 14.6236 18.5315 14.6236C17.5699 14.6236 16.7903 15.4032 16.7903 16.3648C16.7903 17.3264 17.5699 18.106 18.5315 18.106Z" fill="#5e5ce6"/>
<path d="M13.3816 18.106C14.3432 18.106 15.1227 17.3264 15.1227 16.3648C15.1227 15.4032 14.3432 14.6236 13.3816 14.6236C12.4199 14.6236 11.6404 15.4032 11.6404 16.3648C11.6404 17.3264 12.4199 18.106 13.3816 18.106Z" fill="#5e5ce6"/>
<path d="M8.23166 18.106C9.1933 18.106 9.97283 17.3264 9.97283 16.3648C9.97283 15.4032 9.1933 14.6236 8.23166 14.6236C7.27002 14.6236 6.49049 15.4032 6.49049 16.3648C6.49049 17.3264 7.27002 18.106 8.23166 18.106Z" fill="#5e5ce6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

11
public/person.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 12.0289 12.3668">
<g>
<rect height="12.3668" opacity="0" width="12.0289" x="0" y="0"/>
<path d="M1.22801 12.3575L10.3236 12.3575C11.092 12.3575 11.5504 11.9935 11.5504 11.3892C11.5504 9.63047 9.32025 7.21164 5.7724 7.21164C2.23139 7.21164 0 9.63047 0 11.3892C0 11.9935 0.459648 12.3575 1.22801 12.3575ZM5.77801 5.99785C7.26797 5.99785 8.53576 4.67305 8.53576 2.96037C8.53576 1.28051 7.26428 0 5.77801 0C4.29174 0 3.02148 1.30293 3.02148 2.97281C3.02148 4.67305 4.28367 5.99785 5.77801 5.99785Z" fill="black"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 826 B

16
public/touchid.svg Normal file
View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 26.5504 25.7892">
<g>
<rect height="25.7892" opacity="0" width="26.5504" x="0" y="0"/>
<path d="M1.43975 16.9425C1.80912 16.8265 1.99006 16.4433 1.89209 16.0132C1.5949 14.8127 1.42334 13.6116 1.47561 11.973C1.48522 11.5205 1.22975 11.2502 0.848656 11.2162C0.366314 11.1726 0.0717063 11.4729 0.0344408 11.8931C-0.0668094 13.2384 0.0449873 14.7762 0.506237 16.4198C0.631862 16.852 1.04272 17.0702 1.43975 16.9425ZM0.903032 9.83999C1.29561 9.98788 1.74818 9.8039 1.92279 9.33749C3.79897 4.14421 8.26428 1.43906 12.9391 1.43906C15.0166 1.43906 16.6319 1.8546 18.037 2.62617C18.4882 2.87531 18.9609 2.87531 19.1536 2.45062C19.3474 2.02992 19.1449 1.72781 18.7992 1.51078C16.9926 0.397501 15.1879 0 12.9391 0C7.49038 0 2.6878 2.92125 0.525221 8.64234C0.294127 9.24726 0.521002 9.70804 0.903032 9.83999ZM25.529 14.1994C25.942 14.1994 26.2347 13.863 26.1975 13.4191C25.687 8.58632 24.0977 5.58515 21.3654 3.15047C21.0086 2.82469 20.5542 2.91422 20.3276 3.18445C20.0754 3.46007 20.0709 3.88265 20.4394 4.22015C22.7901 6.44811 24.4174 9.23718 24.8095 13.451C24.8606 13.8757 25.1245 14.1994 25.529 14.1994Z" fill="#5e5ce6"/>
<path d="M2.75061 20.3557C2.99647 20.692 3.48397 20.774 3.8374 20.4717C4.69592 19.7463 5.13467 18.5747 5.13467 17.3351C5.13467 15.6968 4.42522 14.8617 4.42522 12.8545C4.42522 8.23124 8.25818 4.39827 12.8815 4.39827C18.4287 4.39827 22.0261 8.9482 22.0357 15.6902C22.0357 18.3478 21.5937 20.2015 21.1664 21.2468C21.0014 21.6354 21.1504 22.0601 21.4889 22.2368C21.8508 22.4006 22.3479 22.2602 22.499 21.8419C22.954 20.6144 23.4747 18.5543 23.4747 15.6785C23.4747 8.09132 19.2876 2.96133 12.8815 2.96133C7.46412 2.96133 2.98616 7.44679 2.98616 12.8545C2.98616 14.7954 3.72116 16.1067 3.73288 17.3234C3.73288 18.1223 3.43569 18.8672 2.90178 19.3455C2.59733 19.6158 2.54202 20.0587 2.75061 20.3557Z" fill="#5e5ce6" fill-opacity="0.5"/>
<path d="M8.15061 16.0235C7.85342 15.2245 7.39826 14.2399 7.39826 12.9122C7.39826 9.80765 9.83459 7.36921 12.9391 7.36921C14.1239 7.36921 14.4926 7.4939 15.5144 7.97882C15.9072 8.15554 16.267 8.04163 16.4107 7.75218C16.6033 7.39242 16.5011 6.95601 16.0711 6.70687C15.2829 6.2414 14.1309 5.92054 12.9391 5.92054C9.04053 5.92054 5.94749 9.0157 5.94749 12.9122C5.94749 14.3676 6.3417 15.5925 6.7371 16.5738C6.9117 16.9985 7.30967 17.1944 7.76319 17.0081C8.14639 16.8569 8.29006 16.4461 8.15061 16.0235Z" fill="#5e5ce6"/>
<path d="M5.61537 22.5712C6.51631 22.1112 7.69147 20.5814 8.03014 19.3765C8.13865 19.0104 7.961 18.5803 7.5724 18.4526C7.21053 18.3248 6.82733 18.543 6.68366 18.9518C6.36514 19.9577 5.82491 20.7101 4.97037 21.2939C4.53373 21.6016 4.44983 21.9881 4.59139 22.2977C4.74889 22.6329 5.16608 22.799 5.61537 22.5712ZM17.4239 9.6103C18.5395 11.269 19.0999 13.5225 19.0999 16.3071C19.0999 19.2813 18.2796 22.0957 16.9959 23.6873C16.7597 23.9981 16.7447 24.4324 17.0236 24.7071C17.3344 24.9923 17.8549 24.9722 18.1104 24.6283C19.5696 22.6589 20.5155 19.5337 20.5155 16.2602C20.5155 12.6187 19.7369 10.5537 18.6874 8.84179C18.4308 8.43726 17.9805 8.30742 17.6229 8.49796C17.2429 8.70234 17.1417 9.18445 17.4239 9.6103Z" fill="#5e5ce6" fill-opacity="0.5"/>
<path d="M8.10795 24.1329C10.0594 22.5628 11.1372 20.0576 11.1255 17.2828C11.1138 15.1844 10.3671 13.8944 10.3671 12.7716C10.3671 11.3098 11.3974 10.3401 12.9103 10.3401C15.1844 10.3401 15.9841 12.8981 16.0629 16.1845C16.1726 19.8155 14.8694 22.9535 13.1034 24.498C12.7969 24.7535 12.7319 25.1526 12.9363 25.473C13.1822 25.8211 13.6835 25.886 14.0135 25.5773C15.8965 23.8669 17.4349 20.1178 17.4136 16.0844C17.3997 11.955 16.1915 8.9032 12.9103 8.9032C10.661 8.9032 8.95358 10.4048 8.95358 12.6459C8.95358 13.9997 9.66303 15.6455 9.67686 17.2828C9.68858 19.6158 8.81717 21.7038 7.2117 23.0046C6.87209 23.2835 6.81678 23.6901 7.03498 24.0148C7.30217 24.3862 7.76623 24.4235 8.10795 24.1329Z" fill="#5e5ce6"/>
<path d="M10.8881 25.0809C11.8223 24.4024 12.9307 22.9073 13.4013 21.4179C13.5108 21.0989 13.4184 20.6358 13.0181 20.5005C12.5817 20.3451 12.2165 20.5655 12.0837 20.9208C11.5134 22.3898 10.8089 23.2945 10.0132 23.9229C9.69491 24.1805 9.60866 24.603 9.82475 24.9351C10.0312 25.2865 10.5283 25.346 10.8881 25.0809ZM14.1199 18.5271C14.4396 16.9071 14.3149 14.8861 13.7365 13.0987C13.5778 12.6335 13.2042 12.427 12.8072 12.5548C12.4315 12.6783 12.2079 13.0326 12.3431 13.4702C12.878 15.1884 12.9696 16.8326 12.7064 18.3281C12.634 18.7176 12.7999 19.0891 13.2417 19.1541C13.6526 19.2286 14.0358 18.9806 14.1199 18.5271Z" fill="#5e5ce6" fill-opacity="0.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

11
public/trash.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 15.8083 19.0759">
<g>
<rect height="19.0759" opacity="0" width="15.8083" x="0" y="0"/>
<path d="M5.40046 14.9841C5.72968 14.9841 5.94187 14.7755 5.93405 14.4683L5.69468 6.27765C5.68546 5.97327 5.47046 5.77109 5.15546 5.77109C4.82906 5.77109 4.61828 5.97968 4.62609 6.28546L4.85906 14.4725C4.86828 14.7833 5.08468 14.9841 5.40046 14.9841ZM7.70171 14.9841C8.02531 14.9841 8.25312 14.7783 8.25312 14.4739L8.25312 6.28265C8.25312 5.97827 8.02531 5.77109 7.70171 5.77109C7.37671 5.77109 7.15531 5.97827 7.15531 6.28265L7.15531 14.4739C7.15531 14.7783 7.37671 14.9841 7.70171 14.9841ZM10.008 14.9841C10.3187 14.9841 10.5337 14.7833 10.543 14.4725L10.7759 6.28546C10.7837 5.97968 10.573 5.77109 10.2466 5.77109C9.93156 5.77109 9.71656 5.97327 9.70734 6.28405L9.47437 14.4683C9.46656 14.7755 9.67875 14.9841 10.008 14.9841ZM4.1525 3.59422L5.59437 3.59422L5.59437 1.97156C5.59437 1.56703 5.87734 1.30749 6.31234 1.30749L9.07687 1.30749C9.51187 1.30749 9.79484 1.56703 9.79484 1.97156L9.79484 3.59422L11.2367 3.59422L11.2367 1.89906C11.2367 0.704374 10.4742 0 9.17968 0L6.20953 0C4.915 0 4.1525 0.704374 4.1525 1.89906ZM0.691402 4.44421L14.717 4.44421C15.102 4.44421 15.402 4.13843 15.402 3.75343C15.402 3.37125 15.102 3.07187 14.717 3.07187L0.691402 3.07187C0.312811 3.07187 0 3.37125 0 3.75343C0 4.14483 0.312811 4.44421 0.691402 4.44421ZM4.11374 17.6291L11.2947 17.6291C12.4906 17.6291 13.2673 16.8969 13.3234 15.6995L13.8605 4.29436L12.4114 4.29436L11.8978 15.4669C11.8822 15.9356 11.573 16.2469 11.1275 16.2469L4.26812 16.2469C3.83546 16.2469 3.52624 15.9278 3.50421 15.4669L2.96499 4.29436L1.54156 4.29436L2.085 15.7059C2.1425 16.9033 2.905 17.6291 4.11374 17.6291Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

7
public/w-bin.svg Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 265 301" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(4.16667,0,0,4.16667,0,0)">
<path d="M22.672,60.656C24.219,60.656 25.188,59.672 25.156,58.203L24.219,26.641C24.172,25.203 23.172,24.234 21.672,24.234C20.156,24.234 19.203,25.219 19.234,26.672L20.156,58.25C20.203,59.703 21.219,60.656 22.672,60.656ZM31.797,60.656C33.281,60.656 34.313,59.703 34.313,58.266L34.313,26.641C34.313,25.203 33.281,24.234 31.797,24.234C30.297,24.234 29.281,25.203 29.281,26.641L29.281,58.266C29.281,59.703 30.297,60.656 31.797,60.656ZM40.922,60.656C42.375,60.656 43.375,59.703 43.422,58.25L44.344,26.672C44.375,25.219 43.422,24.234 41.906,24.234C40.406,24.234 39.406,25.203 39.359,26.656L38.438,58.203C38.406,59.672 39.375,60.656 40.922,60.656ZM16.75,14.547L23.938,14.547L23.938,8.406C23.938,7.078 24.859,6.25 26.359,6.25L37.188,6.25C38.688,6.25 39.609,7.078 39.609,8.406L39.609,14.547L46.797,14.547L46.797,8.156C46.797,2.938 43.547,0 37.719,0L25.828,0C20,0 16.75,2.938 16.75,8.156L16.75,14.547ZM3.516,19.547L60.078,19.547C62.078,19.547 63.578,18.094 63.578,16.094C63.578,14.125 62.078,12.688 60.078,12.688L3.516,12.688C1.531,12.688 0,14.125 0,16.094C0,18.109 1.531,19.547 3.516,19.547ZM17.375,72.156L46.219,72.156C51.563,72.156 54.859,69.188 55.094,63.828L57.172,18.938L50.016,18.938L48.031,62.188C47.969,64.063 46.922,65.188 45.25,65.188L18.313,65.188C16.672,65.188 15.625,64.031 15.547,62.188L13.5,18.938L6.406,18.938L8.5,63.844C8.75,69.203 12,72.156 17.375,72.156Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

8
public/w-checkmark.svg Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,3.0625,3.65625)">
<rect x="0" y="0" width="57.875" height="56.688" style="fill-opacity:0;"/>
<path d="M22.484,56.688C24.609,56.688 26.281,55.859 27.422,54.141L56.516,9.609C57.344,8.359 57.688,7.188 57.688,6.109C57.688,3.188 55.484,1.063 52.5,1.063C50.438,1.063 49.172,1.781 47.906,3.75L22.359,44.094L9.375,28C8.234,26.594 6.969,25.969 5.219,25.969C2.188,25.969 0,28.141 0,31.078C0,32.375 0.406,33.531 1.516,34.828L17.656,54.375C18.984,55.953 20.5,56.688 22.484,56.688Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1018 B

11
public/w-door.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 221 286" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(4.16667,0,0,4.16667,0,0)">
<g>
<rect x="0" y="0" width="52.969" height="68.438" style="fill-opacity:0;"/>
<path d="M52.781,64.406L52.781,8.828C52.781,3.391 49.391,0 43.875,0L8.922,0C3.406,0 0,3.391 0,8.828L0,64.406C0,66.625 1.813,68.438 4.031,68.438C6.25,68.438 8.063,66.625 8.063,64.406L8.063,9.844C8.063,8.75 8.75,8.063 9.797,8.063L42.984,8.063C44.031,8.063 44.719,8.75 44.719,9.844L44.719,64.406C44.719,66.625 46.531,68.438 48.766,68.438C50.984,68.438 52.781,66.625 52.781,64.406Z" style="fill:white;fill-rule:nonzero;"/>
<path d="M40.906,65.75L40.906,12.766C40.906,11.953 40.281,11.563 39.422,11.906L29.906,15.969C29.109,16.281 28.766,16.672 28.766,17.438L28.766,61.063C28.766,61.828 29.109,62.203 29.922,62.547L39.422,66.578C40.281,66.938 40.906,66.547 40.906,65.75Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

11
public/w-login.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(0.967407,0,0,0.967407,-8.88178e-16,0.226712)">
<rect x="0" y="0" width="66.156" height="65.688" style="fill-opacity:0;"/>
<path d="M32.984,6.766C34.859,6.766 36.375,5.25 36.375,3.375C36.375,1.516 34.859,0 32.984,0C31.125,0 29.609,1.516 29.609,3.375C29.609,5.25 31.125,6.766 32.984,6.766ZM42.094,8.234C43.953,8.234 45.484,6.719 45.484,4.859C45.484,2.984 43.953,1.469 42.094,1.469C40.234,1.469 38.703,2.984 38.703,4.859C38.703,6.719 40.234,8.234 42.094,8.234ZM50.328,12.422C52.203,12.422 53.719,10.891 53.719,9.031C53.719,7.172 52.203,5.641 50.328,5.641C48.469,5.641 46.953,7.172 46.953,9.031C46.953,10.891 48.469,12.422 50.328,12.422ZM56.859,18.953C58.719,18.953 60.25,17.438 60.25,15.563C60.25,13.703 58.719,12.188 56.859,12.188C55,12.188 53.469,13.703 53.469,15.563C53.469,17.438 55,18.953 56.859,18.953ZM61.016,27.094C62.875,27.094 64.406,25.563 64.406,23.703C64.406,21.844 62.875,20.313 61.016,20.313C59.156,20.313 57.625,21.844 57.625,23.703C57.625,25.563 59.156,27.094 61.016,27.094ZM62.578,36.219C64.438,36.219 65.969,34.688 65.969,32.828C65.969,30.969 64.438,29.438 62.578,29.438C60.719,29.438 59.188,30.969 59.188,32.828C59.188,34.688 60.719,36.219 62.578,36.219ZM61.016,45.328C62.875,45.328 64.406,43.813 64.406,41.953C64.406,40.078 62.875,38.563 61.016,38.563C59.156,38.563 57.625,40.078 57.625,41.953C57.625,43.813 59.156,45.328 61.016,45.328ZM56.859,53.484C58.719,53.484 60.25,51.969 60.25,50.094C60.25,48.234 58.719,46.719 56.859,46.719C55,46.719 53.469,48.234 53.469,50.094C53.469,51.969 55,53.484 56.859,53.484ZM50.328,60.016C52.203,60.016 53.719,58.484 53.719,56.625C53.719,54.766 52.203,53.234 50.328,53.234C48.469,53.234 46.953,54.766 46.953,56.625C46.953,58.484 48.469,60.016 50.328,60.016ZM42.094,64.188C43.953,64.188 45.484,62.656 45.484,60.797C45.484,58.938 43.953,57.406 42.094,57.406C40.234,57.406 38.703,58.938 38.703,60.797C38.703,62.656 40.234,64.188 42.094,64.188ZM32.984,65.656C34.859,65.656 36.375,64.141 36.375,62.266C36.375,60.406 34.859,58.891 32.984,58.891C31.125,58.891 29.609,60.406 29.609,62.266C29.609,64.141 31.125,65.656 32.984,65.656ZM23.891,64.188C25.75,64.188 27.281,62.656 27.281,60.797C27.281,58.938 25.75,57.406 23.891,57.406C22.016,57.406 20.5,58.938 20.5,60.797C20.5,62.656 22.016,64.188 23.891,64.188ZM15.641,60.016C17.516,60.016 19.031,58.484 19.031,56.625C19.031,54.766 17.516,53.234 15.641,53.234C13.766,53.234 12.266,54.766 12.266,56.625C12.266,58.484 13.766,60.016 15.641,60.016ZM9.109,53.484C10.969,53.484 12.5,51.969 12.5,50.094C12.5,48.234 10.969,46.719 9.109,46.719C7.25,46.719 5.719,48.234 5.719,50.094C5.719,51.969 7.25,53.484 9.109,53.484ZM9.109,18.953C10.969,18.953 12.5,17.438 12.5,15.563C12.5,13.703 10.969,12.188 9.109,12.188C7.25,12.188 5.719,13.703 5.719,15.563C5.719,17.438 7.25,18.953 9.109,18.953ZM15.641,12.422C17.516,12.422 19.031,10.891 19.031,9.031C19.031,7.172 17.516,5.641 15.641,5.641C13.766,5.641 12.266,7.172 12.266,9.031C12.266,10.891 13.766,12.422 15.641,12.422ZM23.891,8.234C25.75,8.234 27.281,6.719 27.281,4.859C27.281,2.984 25.75,1.469 23.891,1.469C22.016,1.469 20.5,2.984 20.5,4.859C20.5,6.719 22.016,8.234 23.891,8.234Z" style="fill:white;fill-rule:nonzero;"/>
<g transform="matrix(1,0,0,1,-5.16845,0)">
<path d="M48.531,32.813C48.531,31.859 48.219,30.953 47.328,30.047L37.719,20.031C37.109,19.422 36.469,19.094 35.563,19.094C33.844,19.094 32.641,20.531 32.641,22.141C32.641,23.016 33.016,23.828 33.656,24.406L37.625,28.141L39.828,29.625L35.359,29.297L14.329,29.297C12.47,29.297 10.876,30.844 10.876,32.813C10.876,34.75 12.47,36.297 14.329,36.297L35.359,36.297L39.828,36L37.625,37.484L33.656,41.203C33.016,41.797 32.641,42.625 32.641,43.5C32.641,45.094 33.844,46.531 35.563,46.531C36.469,46.531 37.109,46.188 37.719,45.578L47.328,35.578C48.219,34.672 48.531,33.734 48.531,32.813Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

12
public/w-passkey.svg Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--Generator: Apple Native CoreSVG 326-->
<!DOCTYPE svg
PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 21.8682 20.7181">
<g>
<rect height="20.7181" opacity="0" width="21.8682" x="0" y="0"/>
<path d="M14.2757 12.3369C14.2924 13.578 14.8884 14.6853 15.8553 15.3753L15.8553 17.4432L5.56472 17.4432C4.65426 17.4432 4.10954 17.0078 4.10954 16.2849C4.10954 14.2792 6.68018 11.5278 10.7271 11.5278C12.0946 11.5278 13.2931 11.8407 14.2757 12.3369ZM13.9046 6.66377C13.9046 8.62435 12.4589 10.1492 10.7324 10.1492C9.00301 10.1492 7.56266 8.62435 7.56266 6.67691C7.56266 4.77278 9.01331 3.26216 10.7324 3.26216C12.4515 3.26216 13.9046 4.75147 13.9046 6.66377Z" fill="white"/>
<path d="M18.0725 9.68825C16.6223 9.68825 15.471 10.8551 15.471 12.2795C15.471 13.3569 16.0886 14.2801 17.0495 14.685L17.0495 18.4528C17.0495 18.5738 17.1063 18.6839 17.1936 18.7794L17.8847 19.4431C17.9976 19.5486 18.1691 19.5614 18.2948 19.4357L19.532 18.2038C19.6598 18.0706 19.6498 17.8843 19.5295 17.7589L18.8579 17.0774L19.8214 16.1225C19.9442 16.0025 19.9467 15.8107 19.8107 15.6673L18.893 14.7574C20.0316 14.2404 20.6712 13.3555 20.6712 12.2795C20.6712 10.8551 19.5121 9.68825 18.0725 9.68825ZM18.0672 10.8672C18.494 10.8672 18.8452 11.2184 18.8452 11.6502C18.8452 12.0803 18.494 12.4365 18.0672 12.4365C17.6428 12.4365 17.2863 12.0803 17.2863 11.6502C17.2863 11.2184 17.6375 10.8672 18.0672 10.8672Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

10
public/w-power.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 276 292" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(4.16667,0,0,4.16667,0,0)">
<g>
<rect x="0" y="0" width="66.203" height="70" style="fill-opacity:0;"/>
<path d="M33,68C51.234,68 66.016,53.203 66.016,35C66.016,25.625 62.125,17.5 56.531,11.969C52.484,7.781 46.344,12.984 50.594,17.563C55.047,22.016 57.797,28.172 57.797,35C57.797,48.703 46.703,59.781 33,59.781C19.297,59.781 8.219,48.703 8.219,35C8.219,28.125 10.984,22 15.422,17.547C19.688,12.953 13.563,7.844 9.484,11.984C3.859,17.453 0,25.781 0,35C0,53.203 14.781,68 33,68ZM33,35.781C35.641,35.781 37.391,33.922 37.391,31.141L37.391,4.625C37.391,1.844 35.641,0 33,0C30.375,0 28.641,1.844 28.641,4.625L28.641,31.141C28.641,33.922 30.375,35.781 33,35.781Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

8
public/w-right.svg Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(1,0,0,1,1.4844,6.7969)">
<rect x="0" y="0" width="61.031" height="50.406" style="fill-opacity:0;"/>
<path d="M60.844,25.094C60.844,23.859 60.328,22.594 59.469,21.75L39.125,1.453C38.156,0.469 37.047,0 35.938,0C33.281,0 31.484,1.844 31.484,4.297C31.484,5.656 32.063,6.703 32.906,7.531L39.906,14.563L50.25,24.078L52.156,21.219L40.016,20.563L4.688,20.563C1.875,20.563 0,22.391 0,25.094C0,27.797 1.875,29.625 4.688,29.625L40.016,29.625L52.156,28.969L50.25,26.141L39.906,35.625L32.906,42.641C32.063,43.438 31.484,44.516 31.484,45.875C31.484,48.328 33.281,50.172 35.938,50.172C37.047,50.172 38.156,49.688 39.094,48.75L59.469,28.438C60.328,27.594 60.844,26.328 60.844,25.094Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

10
public/w-xmark.svg Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 217 216" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g transform="matrix(4.16667,0,0,4.16667,0,0)">
<g>
<rect x="0" y="0" width="51.887" height="51.762" style="fill-opacity:0;"/>
<path d="M1.342,50.412C3.17,52.209 6.311,52.162 8.014,50.459L25.857,32.615L43.67,50.443C45.436,52.209 48.514,52.209 50.326,50.397C52.139,48.568 52.154,45.522 50.389,43.74L32.576,25.897L50.389,8.084C52.154,6.318 52.154,3.256 50.326,1.443C48.498,-0.385 45.436,-0.4 43.67,1.381L25.857,19.193L8.014,1.365C6.311,-0.338 3.154,-0.416 1.342,1.428C-0.455,3.256 -0.408,6.365 1.295,8.068L19.139,25.897L1.295,43.772C-0.408,45.459 -0.471,48.6 1.342,50.412Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,5 +1,6 @@
// This file is auto-generated by the `update-kc-gen` command. Do not edit it manually. // This file is auto-generated by the `update-kc-gen` command. Do not edit it manually.
// Hash: 09b09a6c36072d5cf2f8484ab3dc720d28ec8c126df1bafb0b2214a0139848c7 // Hash: 612dc705f216ef0750f6fe7480dd13bcc1c0af561a77e7ce5ffd6a39e53d185d
/* eslint-disable */ /* eslint-disable */
@ -9,9 +10,9 @@
import { lazy, Suspense, type ReactNode } from "react"; import { lazy, Suspense, type ReactNode } from "react";
export type ThemeName = "keycloakify-starter"; export type ThemeName = "auth-tombutcher-work";
export const themeNames: ThemeName[] = ["keycloakify-starter"]; export const themeNames: ThemeName[] = ["auth-tombutcher-work"];
export type KcEnvName = never; export type KcEnvName = never;
@ -20,11 +21,13 @@ export const kcEnvNames: KcEnvName[] = [];
export const kcEnvDefaults: Record<KcEnvName, string> = {}; export const kcEnvDefaults: Record<KcEnvName, string> = {};
/** /**
* NOTE: Do not import this type except maybe in your entrypoint. * NOTE: Do not import this type except maybe in your entrypoint.
* If you need to import the KcContext import it either from src/login/KcContext.ts or src/account/KcContext.ts. * If you need to import the KcContext import it either from src/login/KcContext.ts or src/account/KcContext.ts.
* Depending on the theme type you are working on. * Depending on the theme type you are working on.
*/ */
export type KcContext = import("./login/KcContext").KcContext; export type KcContext =
| import("./login/KcContext").KcContext
;
declare global { declare global {
interface Window { interface Window {
@ -34,14 +37,18 @@ declare global {
export const KcLoginPage = lazy(() => import("./login/KcPage")); export const KcLoginPage = lazy(() => import("./login/KcPage"));
export function KcPage(props: { kcContext: KcContext; fallback?: ReactNode }) { export function KcPage(
props: {
kcContext: KcContext;
fallback?: ReactNode;
}
) {
const { kcContext, fallback } = props; const { kcContext, fallback } = props;
return ( return (
<Suspense fallback={fallback}> <Suspense fallback={fallback}>
{(() => { {(() => {
switch (kcContext.themeType) { switch (kcContext.themeType) {
case "login": case "login": return <KcLoginPage kcContext={kcContext} />;
return <KcLoginPage kcContext={kcContext} />;
} }
})()} })()}
</Suspense> </Suspense>

View File

@ -3,10 +3,51 @@ import type { ClassKey } from "keycloakify/login";
import type { KcContext } from "./KcContext"; import type { KcContext } from "./KcContext";
import { useI18n } from "./i18n"; import { useI18n } from "./i18n";
import DefaultPage from "keycloakify/login/DefaultPage"; import DefaultPage from "keycloakify/login/DefaultPage";
import Template from "keycloakify/login/Template"; import Template from "./Template";
const UserProfileFormFields = lazy( const UserProfileFormFields = lazy(() => import("./UserProfileFormFields"));
() => import("keycloakify/login/UserProfileFormFields")
import "./custom.css";
import "./fonts.css";
const Login = lazy(() => import("./pages/Login"));
const LoginUsername = lazy(() => import("./pages/LoginUsername"));
const LoginPassword = lazy(() => import("./pages/LoginPassword"));
const Register = lazy(() => import("./pages/Register"));
const Error = lazy(() => import("./pages/Error"));
const WebauthnAuthenticate = lazy(() => import("./pages/WebauthnAuthenticate"));
const WebauthnRegister = lazy(() => import("./pages/WebauthnRegister"));
const LoginResetPassword = lazy(() => import("./pages/LoginResetPassword"));
const Info = lazy(() => import("./pages/Info"));
const Terms = lazy(() => import("./pages/Terms"));
const LogoutConfirm = lazy(() => import("./pages/LogoutConfirm"));
const LoginUpdatePassword = lazy(() => import("./pages/LoginUpdatePassword"));
const LoginUpdateProfile = lazy(() => import("./pages/LoginUpdateProfile"));
const LoginOauth2DeviceVerifyUserCode = lazy(
() => import("./pages/LoginOauth2DeviceVerifyUserCode")
); );
const LoginVerifyEmail = lazy(() => import("./pages/LoginVerifyEmail"));
const LoginOtp = lazy(() => import("./pages/LoginOtp"));
const LoginIdpLinkConfirm = lazy(() => import("./pages/LoginIdpLinkConfirm"));
const LoginIdpLinkEmail = lazy(() => import("./pages/LoginIdpLinkEmail"));
const LoginOauthGrant = lazy(() => import("./pages/LoginOauthGrant"));
const LoginConfigTotp = lazy(() => import("./pages/LoginConfigTotp"));
const UpdateEmail = lazy(() => import("./pages/UpdateEmail"));
const SelectAuthenticator = lazy(() => import("./pages/SelectAuthenticator"));
const SamlPostForm = lazy(() => import("./pages/SamlPostForm"));
const DeleteCredential = lazy(() => import("./pages/DeleteCredential"));
const Code = lazy(() => import("./pages/Code"));
const DeleteAccountConfirm = lazy(() => import("./pages/DeleteAccountConfirm"));
const FrontchannelLogout = lazy(() => import("./pages/FrontchannelLogout"));
const LoginRecoveryAuthnCodeConfig = lazy(
() => import("./pages/LoginRecoveryAuthnCodeConfig")
);
const LoginRecoveryAuthnCodeInput = lazy(
() => import("./pages/LoginRecoveryAuthnCodeInput")
);
const LoginPageExpired = lazy(() => import("./pages/LoginPageExpired"));
const IdpReviewUserProfile = lazy(() => import("./pages/IdpReviewUserProfile"));
const LoginResetOtp = lazy(() => import("./pages/LoginResetOtp"));
const WebauthnError = lazy(() => import("./pages/WebauthnError"));
const doMakeUserConfirmPassword = true; const doMakeUserConfirmPassword = true;
@ -19,6 +60,304 @@ export default function KcPage(props: { kcContext: KcContext }) {
<Suspense> <Suspense>
{(() => { {(() => {
switch (kcContext.pageId) { switch (kcContext.pageId) {
case "login.ftl":
return (
<Login
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-username.ftl":
return (
<LoginUsername
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-password.ftl":
return (
<LoginPassword
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
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}
doUseDefaultCss={false}
UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
</Template>
);
case "error.ftl":
return (
<Error
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "webauthn-authenticate.ftl":
return (
<WebauthnAuthenticate
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "webauthn-register.ftl":
return (
<WebauthnRegister
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "webauthn-error.ftl":
return (
<WebauthnError
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-reset-password.ftl":
return (
<LoginResetPassword
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "info.ftl":
return (
<Info
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "terms.ftl":
return (
<Terms
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "logout-confirm.ftl":
return (
<LogoutConfirm
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-update-password.ftl":
return (
<LoginUpdatePassword
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
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}
doUseDefaultCss={true}
UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
</Template>
);
case "login-oauth2-device-verify-user-code.ftl":
return (
<LoginOauth2DeviceVerifyUserCode
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-verify-email.ftl":
return (
<LoginVerifyEmail
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-otp.ftl":
return (
<LoginOtp
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-idp-link-confirm.ftl":
return (
<LoginIdpLinkConfirm
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-idp-link-email.ftl":
return (
<LoginIdpLinkEmail
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-oauth-grant.ftl":
return (
<LoginOauthGrant
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-config-totp.ftl":
return (
<LoginConfigTotp
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "update-email.ftl":
return (
<UpdateEmail
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
);
case "select-authenticator.ftl":
return (
<SelectAuthenticator
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "saml-post-form.ftl":
return (
<SamlPostForm
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "delete-credential.ftl":
return (
<DeleteCredential
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "code.ftl":
return (
<Code
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "delete-account-confirm.ftl":
return (
<DeleteAccountConfirm
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "frontchannel-logout.ftl":
return (
<FrontchannelLogout
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-recovery-authn-code-config.ftl":
return (
<LoginRecoveryAuthnCodeConfig
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-recovery-authn-code-input.ftl":
return (
<LoginRecoveryAuthnCodeInput
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "login-page-expired.ftl":
return (
<LoginPageExpired
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
case "idp-review-user-profile.ftl":
return (
<IdpReviewUserProfile
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
UserProfileFormFields={UserProfileFormFields}
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
);
case "login-reset-otp.ftl":
return (
<LoginResetOtp
{...{ kcContext, i18n, classes }}
Template={Template}
doUseDefaultCss={true}
/>
);
default: default:
return ( return (
<DefaultPage <DefaultPage

View File

@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from "react";
import Particles, { initParticlesEngine } from "@tsparticles/react";
import { type Container, type ISourceOptions } from "@tsparticles/engine";
// import { loadAll } from "@tsparticles/all"; // if you are going to use `loadAll`, install the "@tsparticles/all" package too.
// import { loadFull } from "tsparticles"; // if you are going to use `loadFull`, install the "tsparticles" 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.
const ParticlesBackground = () => {
const [init, setInit] = useState(false);
// this should be run only once per application lifetime
useEffect(() => {
initParticlesEngine(async engine => {
// you can initiate the tsParticles instance (engine) here, adding custom shapes or presets
// this loads the tsparticles package bundle, it's the easiest method for getting everything ready
// starting from v2 you can add only the features you need reducing the bundle size
//await loadAll(engine);
//await loadFull(engine);
await loadSlim(engine);
//await loadBasic(engine);
}).then(() => {
setInit(true);
});
}, []);
const particlesLoaded = async (container?: Container): Promise<void> => {
console.log(container);
};
const options: 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: [
"#FF00A1",
"#0310FF",
"#2DE2FF",
"#6E00FF",
"#0310FF",
"#FF00A1"
]
},
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
}),
[]
);
if (init) {
return (
<div
style={{
width: "100vw",
height: "var(--unit-100vh)",
position: "absolute",
zIndex: 1
}}
>
<Particles
id="tsparticles"
particlesLoaded={particlesLoaded}
options={options}
/>
<div
style={{
background: "rgba(255, 255, 255, 0.0)",
backdropFilter: "blur(50px)",
width: "100%",
height: "100%",
top: 0,
left: 0,
position: "absolute",
display: "inline"
}}
></div>
</div>
);
}
return <></>;
};
export default ParticlesBackground;

224
src/login/Template.tsx Normal file
View File

@ -0,0 +1,224 @@
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, Button, Alert, Space, Divider, ConfigProvider, Flex, theme } from "antd";
import { GlobalOutlined } from "@ant-design/icons";
import ParticlesBackground from "./ParticlesBackground";
const { Sider, Content } = Layout;
const { Title, Text } = Typography;
export default function Template(props: TemplateProps<KcContext, I18n>) {
const {
displayInfo = false,
displayMessage = true,
displayRequiredFields = false,
headerNode,
infoNode = null,
documentTitle,
bodyClassName,
kcContext,
i18n,
doUseDefaultCss,
children
} = props;
const { msg, msgStr, currentLanguage, enabledLanguages } = i18n;
const { realm, auth, url, message, isAppInitiatedAction } = kcContext;
const isMobile = useMediaQuery({ maxWidth: 600 });
const [darkMode, setDarkMode] = useState(false);
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(() => {
console.log(windowQuery.matches ? true : false);
setDarkMode(windowQuery.matches ? true : false);
}, []);
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>
}))
};
return (
<ConfigProvider
theme={{
token: {
colorPrimary: "rgba(212, 0, 255, 1)",
colorLink: "#6E00FF",
colorLinkHover: "#b175ff"
},
algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm
}}
>
<Layout style={{ minHeight: "var(--unit-100vh)", maxHeight: "var(--unit-100vh)" }}>
<ParticlesBackground />
<Sider
width={isMobile ? "100%" : "500px"}
style={{
backgroundColor: darkMode ? "#141414" : "#fff",
boxShadow: "2px 0 8px rgba(0,0,0,0.1)",
padding: "40px 40px",
paddingRight: "20px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
zIndex: 1
}}
>
<Flex style={{ height: "100%" }} vertical>
<div style={{ marginRight: "20px" }}>
<img src="https://cdn.tombutcher.work/logos/logo-auth.png" alt="Logo" style={{ width: "220px" }} />
<Divider style={{ margin: "24px 0" }} />
<div style={{ marginBottom: "24px" }}>
{(() => {
const node = !(auth !== undefined && auth.showUsername && !auth.showResetCredentials) ? (
<Title level={2} style={{ marginBottom: "16px" }}>
{headerNode}
</Title>
) : (
<>
<div style={{ display: "flex", alignItems: "center", marginBottom: "24px" }}>
<Flex gap={"small"}>
<img
src={"https://cdn.tombutcher.work/icons/auth/c-person.svg"}
width={14}
style={{ marginTop: "3px" }}
/>
<Text strong>{auth.attemptedUsername}</Text>
</Flex>
<Button
type="link"
href={url.loginRestartFlowUrl}
title={msgStr("restartLoginTooltip")}
style={{ marginLeft: "12px" }}
>
{msg("restartLoginTooltip")}
</Button>
</div>
<Divider />
</>
);
if (displayRequiredFields) {
return (
<div>
<div style={{ marginBottom: "12px" }}>
<Text type="secondary">
<span style={{ color: "#ff4d4f" }}>*</span> {msg("requiredFields")}
</Text>
</div>
{node}
</div>
);
}
return node;
})()}
</div>
{/* App-initiated actions should not see warning messages about the need to complete the action during login. */}
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<Alert
message={<span dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />}
type={
message.type === "error"
? "error"
: message.type === "success"
? "success"
: message.type === "warning"
? "warning"
: "info"
}
showIcon
style={{ marginBottom: "24px" }}
/>
)}
</div>
<div style={{ overflowY: "auto", paddingRight: "20px", height: "100%" }}>
{children}
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<input type="hidden" name="tryAnotherWay" value="on" />
<div style={{ textAlign: "center", margin: "16px 0" }}>
<Button
type="link"
onClick={() => {
document.forms["kc-select-try-another-way-form" as never].submit();
return false;
}}
>
{msg("doTryAnotherWay")}
</Button>
</div>
</form>
)}
{displayInfo && <div style={{ marginTop: "24px", textAlign: "center" }}>{infoNode}</div>}
</div>
{enabledLanguages.length > 1 && (
<div style={{ paddingRight: "20px" }}>
<Divider style={{ margin: "24px 0" }} />
<Dropdown menu={languageItems} trigger={["click"]}>
<Button style={{ width: "100%", textAlign: "left" }}>
<Space>
{currentLanguage.label}
<GlobalOutlined />
</Space>
</Button>
</Dropdown>
</div>
)}
</Flex>
</Sider>
<Content
style={{
background: "#f5f5f5",
display: "flex",
alignItems: "center",
justifyContent: "center"
}}
></Content>
</Layout>
</ConfigProvider>
);
}

View File

@ -0,0 +1,536 @@
import type { JSX } from "keycloakify/tools/JSX";
import { assert } from "keycloakify/tools/assert";
import { useIsPasswordRevealed } from "keycloakify/tools/useIsPasswordRevealed";
import {
useUserProfileForm,
getButtonToDisplayForMultivaluedAttributeField,
type FormAction,
type FormFieldError
} from "keycloakify/login/lib/useUserProfileForm";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps";
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 { EyeOutlined, EyeInvisibleOutlined, PlusOutlined, MinusOutlined } from "@ant-design/icons";
const { Text, Title } = Typography;
const { TextArea } = Input;
const { Group: RadioGroup } = Radio;
const { Group: CheckboxGroup } = Checkbox;
export default function UserProfileFormFields(props: UserProfileFormFieldsProps<KcContext, I18n> & FormItemProps) {
const { kcContext, i18n, kcClsx, doMakeUserConfirmPassword, BeforeField, AfterField, ...rest } = props;
const { advancedMsg } = i18n;
const {
formState: { formFieldStates },
dispatchFormAction
} = useUserProfileForm({
kcContext,
i18n,
doMakeUserConfirmPassword
});
const groupNameRef = { current: "" };
return (
<>
{formFieldStates.map(({ attribute, displayableErrors, valueOrValues }) => {
return (
<>
<GroupLabel attribute={attribute} groupNameRef={groupNameRef} i18n={i18n} />
<Form.Item
label={advancedMsg(attribute.displayName ?? "")}
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
key={attribute.name}
{...rest}
>
{BeforeField !== undefined && (
<BeforeField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
valueOrValues={valueOrValues}
kcClsx={kcClsx}
i18n={i18n}
/>
)}
<InputFieldByType
attribute={attribute}
valueOrValues={valueOrValues}
displayableErrors={displayableErrors}
dispatchFormAction={dispatchFormAction}
i18n={i18n}
/>
{AfterField !== undefined && (
<AfterField
attribute={attribute}
dispatchFormAction={dispatchFormAction}
displayableErrors={displayableErrors}
valueOrValues={valueOrValues}
kcClsx={kcClsx}
i18n={i18n}
/>
)}
</Form.Item>
</>
);
})}
</>
);
}
function GroupLabel(props: {
attribute: Attribute;
groupNameRef: {
current: string;
};
i18n: I18n;
}) {
const { attribute, groupNameRef, i18n } = props;
const { advancedMsg } = i18n;
if (attribute.group?.name !== groupNameRef.current) {
groupNameRef.current = attribute.group?.name ?? "";
if (groupNameRef.current !== "") {
assert(attribute.group !== undefined);
return (
<div {...Object.fromEntries(Object.entries(attribute.group.html5DataAnnotations).map(([key, value]) => [`data-${key}`, value]))}>
{(() => {
const groupDisplayHeader = attribute.group.displayHeader ?? "";
const groupHeaderText = groupDisplayHeader !== "" ? advancedMsg(groupDisplayHeader) : attribute.group.name;
return (
<>
<Divider />
<Title level={4} id={`header-${attribute.group.name}`}>
{groupHeaderText}
</Title>
</>
);
})()}
{(() => {
const groupDisplayDescription = attribute.group.displayDescription ?? "";
if (groupDisplayDescription !== "") {
const groupDescriptionText = advancedMsg(groupDisplayDescription);
return <Text id={`description-${attribute.group.name}`}>{groupDescriptionText}</Text>;
}
return null;
})()}
</div>
);
}
}
return null;
}
function FieldErrors(props: { attribute: Attribute; displayableErrors: FormFieldError[]; fieldIndex: number | undefined }) {
const { attribute, fieldIndex } = props;
const displayableErrors = props.displayableErrors.filter(error => error.fieldIndex === fieldIndex);
if (displayableErrors.length === 0) {
return null;
}
return (
<div id={`input-error-${attribute.name}${fieldIndex === undefined ? "" : `-${fieldIndex}`}`} aria-live="polite">
{displayableErrors
.filter(error => error.fieldIndex === fieldIndex)
.map(({ errorMessage }, i) => (
<span key={i}>{errorMessage}</span>
))}
</div>
);
}
type InputFieldByTypeProps = {
attribute: Attribute;
valueOrValues: string | string[];
displayableErrors: FormFieldError[];
dispatchFormAction: React.Dispatch<FormAction>;
i18n: I18n;
};
function InputFieldByType(props: InputFieldByTypeProps & InputProps) {
const { attribute, valueOrValues } = props;
switch (attribute.annotations.inputType) {
case "textarea":
return <TextareaTag {...props} />;
case "select":
case "multiselect":
return <SelectTag {...props} />;
case "select-radiobuttons":
case "multiselect-checkboxes":
return <InputTagSelects {...props} />;
default: {
if (valueOrValues instanceof Array) {
return (
<>
{valueOrValues.map((...[, i]) => (
<InputTag key={i} {...props} fieldIndex={i} />
))}
</>
);
}
const inputNode = <InputTag {...props} fieldIndex={undefined} />;
if (attribute.name === "password" || attribute.name === "password-confirm") {
return (
<PasswordWrapper i18n={props.i18n} passwordInputId={attribute.name}>
{inputNode}
</PasswordWrapper>
);
}
return inputNode;
}
}
}
function PasswordWrapper(props: { i18n: I18n; passwordInputId: string; children: JSX.Element }) {
const { passwordInputId } = props;
const form = Form.useFormInstance();
// const { msgStr } = i18n;
const { isPasswordRevealed, toggleIsPasswordRevealed } = useIsPasswordRevealed({ passwordInputId });
return (
<Input.Password
id={passwordInputId}
iconRender={visible => (visible ? <EyeOutlined /> : <EyeInvisibleOutlined />)}
onChange={e => form.setFieldsValue({ [passwordInputId]: e.target.value })}
visibilityToggle={{
visible: isPasswordRevealed,
onVisibleChange: toggleIsPasswordRevealed
}}
/>
);
}
function InputTag(props: InputFieldByTypeProps & { fieldIndex: number | undefined } & InputProps) {
const { attribute, fieldIndex, dispatchFormAction, valueOrValues, i18n, displayableErrors, ...rest } = props;
const { advancedMsgStr } = i18n;
const inputValue = (() => {
if (fieldIndex !== undefined) {
assert(valueOrValues instanceof Array);
return valueOrValues[fieldIndex];
}
assert(typeof valueOrValues === "string");
return valueOrValues;
})();
const inputType = (() => {
const { inputType } = attribute.annotations;
if (inputType?.startsWith("html5-")) {
return inputType.slice(6);
}
return inputType ?? "text";
})();
// For password fields, we're using the PasswordWrapper component
if (attribute.name === "password" || attribute.name === "password-confirm") {
return null;
}
const inputElement = (
<Input
form=""
type={inputType}
disabled={attribute.readOnly}
defaultValue={inputValue}
placeholder={
attribute.annotations.inputTypePlaceholder === undefined ? undefined : advancedMsgStr(attribute.annotations.inputTypePlaceholder)
}
pattern={attribute.annotations.inputTypePattern}
min={attribute.annotations.inputTypeMin}
max={attribute.annotations.inputTypeMax}
step={attribute.annotations.inputTypeStep}
status={displayableErrors.find(error => error.fieldIndex === fieldIndex) ? "error" : undefined}
{...rest}
/>
);
return (
<>
{inputElement}
{(() => {
if (fieldIndex === undefined) {
return null;
}
assert(valueOrValues instanceof Array);
const values = valueOrValues;
return (
<>
<FieldErrors attribute={attribute} displayableErrors={displayableErrors} fieldIndex={fieldIndex} />
<AddRemoveButtonsMultiValuedAttribute
attribute={attribute}
values={values}
fieldIndex={fieldIndex}
dispatchFormAction={dispatchFormAction}
i18n={i18n}
/>
</>
);
})()}
</>
);
}
function AddRemoveButtonsMultiValuedAttribute(props: {
attribute: Attribute;
values: string[];
fieldIndex: number;
dispatchFormAction: React.Dispatch<Extract<FormAction, { action: "update" }>>;
i18n: I18n;
}) {
const { attribute, values, fieldIndex, dispatchFormAction, i18n } = props;
const { msg } = i18n;
const { hasAdd, hasRemove } = getButtonToDisplayForMultivaluedAttributeField({ attribute, values, fieldIndex });
return (
<Space>
{hasRemove && (
<Button
id={`kc-remove-${attribute.name}-${fieldIndex + 1}`}
type="link"
icon={<MinusOutlined />}
onClick={() =>
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: values.filter((_, i) => i !== fieldIndex)
})
}
>
{msg("remove")}
</Button>
)}
{hasAdd && (
<Button
id={`kc-add-${attribute.name}-${fieldIndex + 1}`}
type="link"
icon={<PlusOutlined />}
onClick={() =>
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: [...values, ""]
})
}
>
{msg("addValue")}
</Button>
)}
</Space>
);
}
function InputTagSelects(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, i18n, valueOrValues } = props;
const { inputType } = attribute.annotations;
assert(inputType === "select-radiobuttons" || inputType === "multiselect-checkboxes");
const options = (() => {
walk: {
const { inputOptionsFromValidation } = attribute.annotations;
if (inputOptionsFromValidation === undefined) {
break walk;
}
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
if (validator === undefined) {
break walk;
}
if (validator.options === undefined) {
break walk;
}
return validator.options;
}
return attribute.validators.options?.options ?? [];
})();
const antOptions = options.map(option => ({
label: inputLabel(i18n, attribute, option),
value: option
}));
const handleChange = (checkedValue: string | string[]) => {
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: checkedValue
});
};
if (inputType === "select-radiobuttons") {
return (
<RadioGroup
name={attribute.name}
options={antOptions}
defaultValue={valueOrValues as string}
onChange={e => handleChange(e.target.value)}
disabled={attribute.readOnly}
/>
);
} else {
// For multiselect-checkboxes
return (
<CheckboxGroup
name={attribute.name}
options={antOptions}
defaultValue={valueOrValues as string[]}
onChange={checkedValues => handleChange(checkedValues as string[])}
disabled={attribute.readOnly}
/>
);
}
}
function TextareaTag(props: InputFieldByTypeProps) {
const { attribute, dispatchFormAction, displayableErrors, valueOrValues } = props;
assert(typeof valueOrValues === "string");
const value = valueOrValues;
return (
<TextArea
id={attribute.name}
name={attribute.name}
status={displayableErrors.length > 0 ? "error" : undefined}
disabled={attribute.readOnly}
rows={attribute.annotations.inputTypeRows === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeRows}`)}
maxLength={attribute.annotations.inputTypeMaxlength === undefined ? undefined : parseInt(`${attribute.annotations.inputTypeMaxlength}`)}
defaultValue={value}
onChange={event =>
dispatchFormAction({
action: "update",
name: attribute.name,
valueOrValues: event.target.value
})
}
onBlur={() =>
dispatchFormAction({
action: "focus lost",
name: attribute.name,
fieldIndex: undefined
})
}
/>
);
}
function SelectTag(props: InputFieldByTypeProps) {
const { attribute, displayableErrors, i18n, valueOrValues, ...rest } = props;
const isMultiple = attribute.annotations.inputType === "multiselect";
const options = (() => {
walk: {
const { inputOptionsFromValidation } = attribute.annotations;
if (inputOptionsFromValidation === undefined) {
break walk;
}
assert(typeof inputOptionsFromValidation === "string");
const validator = (attribute.validators as Record<string, { options?: string[] }>)[inputOptionsFromValidation];
if (validator === undefined) {
break walk;
}
if (validator.options === undefined) {
break walk;
}
return validator.options;
}
return attribute.validators.options?.options ?? [];
})();
return (
<Select
id={attribute.name}
status={displayableErrors.length > 0 ? "error" : undefined}
disabled={attribute.readOnly}
mode={isMultiple ? "multiple" : undefined}
size={attribute.annotations.inputTypeSize === undefined ? undefined : undefined} // Ant Design uses 'large', 'middle', 'small'
defaultValue={valueOrValues}
placeholder={isMultiple ? undefined : ""}
{...rest}
>
{options.map(option => (
<Select.Option key={option} value={option}>
{inputLabel(i18n, attribute, option)}
</Select.Option>
))}
</Select>
);
}
function inputLabel(i18n: I18n, attribute: Attribute, option: string) {
const { advancedMsg } = i18n;
if (attribute.annotations.inputOptionLabels !== undefined) {
const { inputOptionLabels } = attribute.annotations;
return advancedMsg(inputOptionLabels[option] ?? option);
}
if (attribute.annotations.inputOptionLabelsI18nPrefix !== undefined) {
return advancedMsg(`${attribute.annotations.inputOptionLabelsI18nPrefix}.${option}`);
}
return option;
}

204
src/login/custom.css Normal file
View File

@ -0,0 +1,204 @@
:root {
--unit-100vh: 100vh;
}
@supports (height: 100dvh) {
:root {
--unit-100vh: 100dvh;
}
}
/* Targeting the scrollbar */
::-webkit-scrollbar {
width: 1px; /* Width of the scrollbar */
height: 10px; /* Height of the scrollbar for horizontal scrolling */
}
/* The track of the scrollbar (background) */
::-webkit-scrollbar-track {
background: transparent; /* Light grey background */
border-radius: 10px;
}
/* The handle of the scrollbar (the draggable part) */
::-webkit-scrollbar-thumb {
background-color: #80808020; /* Grey color for the scrollbar thumb */
border-radius: 10px;
margin: 15px;
}
/* Hover effect on the scrollbar thumb */
::-webkit-scrollbar-thumb:hover {
background-color: #80808080;
}
body {
margin: 0;
overflow: hidden;
}
h2 span {
background: rgb(110, 0, 255);
background: linear-gradient(
45deg,
rgba(110, 0, 255, 1) 0%,
rgba(212, 0, 255, 1) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.ant-typography a,
a.ant-typography,
.ant-alert-message a,
.ant-btn-color-link.ant-btn-variant-link,
.ant-btn-color-link.ant-btn-variant-link:hover,
.ant-typography code {
color: black;
background: rgb(110, 0, 255);
background: linear-gradient(
45deg,
rgba(110, 0, 255, 1) 0%,
rgba(212, 0, 255, 1) 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.ant-checkbox-checked .ant-checkbox-inner {
background: rgb(110, 0, 255);
background: linear-gradient(
45deg,
rgba(160, 0, 255, 1) 0%,
rgba(212, 0, 255, 1) 100%
);
}
.ant-checkbox:not(.ant-checkbox-disabled):hover .ant-checkbox-inner {
}
.kctbform > div,
.kctbform > div {
margin-bottom: 24px;
}
.kctbform > div > div,
.kctbform > div > div {
margin-bottom: 8px;
width: 100%;
padding: 0;
}
.kctbform > div > div > div {
display: flex;
}
.kctbform > div > div > div > input {
border-top-right-radius: 0px;
border-bottom-right-radius: 0px;
border-right: 0;
}
.kctbform > div > div > div > button {
background: #ffffff;
border-width: 1px;
border-style: solid;
border-color: #d9d9d9;
border-left: 0;
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
background-repeat: no-repeat;
}
.kctbform > div > div > div > button i {
background-image: url("/eye.svg");
width: 14px;
display: block;
height: 9px;
background-repeat: no-repeat;
}
.kctbform div > div input,
.kctbform div > div input {
background: #ffffff;
border-width: 1px;
border-style: solid;
border-color: #d9d9d9;
padding: 4px 11px;
border-radius: 6px;
transition: all 0.2s;
line-height: 1.5714285714285714;
border-width: 1px;
border-style: solid;
flex-grow: 1;
width: 100%;
background-image: none !important;
}
.kctbform div span > span,
.kctbform div > div span > span {
color: rgb(255, 77, 79);
font-weight: 400;
}
.icon24 {
display: block;
width: 24px;
height: 24px;
background-position: center;
background-repeat: no-repeat;
}
.kcAuthenticatorOTPClass {
background-image: url("https://cdn.tombutcher.work/icons/auth/c-phone-v2.svg");
}
.kcAuthenticatorWebAuthnClass {
background-image: url("https://cdn.tombutcher.work/icons/auth/c-passkey-v2.svg");
}
.kcAuthenticatorPasswordClass {
background-image: url("https://cdn.tombutcher.work/icons/auth/c-pencil-v2.svg");
}
.kcAuthenticatorWebAuthnPasswordlessClass {
background-image: url("https://cdn.tombutcher.work/icons/auth/c-passkey-v2.svg");
}
.kcAuthenticatorDefaultClass {
background-image: url("https://cdn.tombutcher.work/icons/auth/c-default-v2.svg");
}
.ant-btn-icon > img {
margin-bottom: 2px;
}
.ant-steps-item
> .ant-steps-item-container
> .ant-steps-item-content
> .ant-steps-item-title::after {
background: transparent !important;
}
.ant-steps
.ant-steps-item-finish
> .ant-steps-item-container
> .ant-steps-item-tail::after {
background: transparent !important;
}
.ant-steps
.ant-steps-item-finish
> .ant-steps-item-container
> .ant-steps-item-tail::after {
background: transparent !important;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-background-clip: text;
-webkit-text-fill-color: #ffffff;
transition: background-color 5000s ease-in-out 0s;
box-shadow: inset 0 0 20px 20px #23232329;
}

317
src/login/fonts.css Normal file
View File

@ -0,0 +1,317 @@
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 900;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Black-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 700;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Bold-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 800;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Heavy-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: normal;
font-weight: 200;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Light-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 500;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Medium-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 400;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Regular-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 600;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Semibold-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 300;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Thin-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Pro-Rounded";
font-style: normal;
font-weight: 100;
src:
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Pro-Rounded-Ultralight-Subset.otf")
format("opentype");
}
@font-face {
font-family: "Grold-Rounded-Slim";
font-style: normal;
font-weight: normal;
src:
url("https://cdn.tombutcher.work/fonts/Grold-Rounded-Slim-Bold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/Grold-Rounded-Slim-Bold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/Grold-Rounded-Slim-Bold-Subset.otf")
format("truetype");
}
@font-face {
font-family: "SF-Compact-Rounded";
font-style: normal;
font-weight: 900;
src:
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Black-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Black-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Black-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Compact-Rounded";
font-style: normal;
font-weight: 700;
src:
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Bold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Bold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Bold-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Compact-Rounded";
font-style: normal;
font-weight: 800;
src:
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Heavy-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Heavy-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Heavy-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Compact-Rounded";
font-style: normal;
font-weight: 200;
src:
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Light-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Light-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Light-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Compact-Rounded";
font-style: normal;
font-weight: 500;
src:
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Medium-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Medium-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Medium-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Compact-Rounded";
font-style: normal;
font-weight: 400;
src:
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Regular-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Regular-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Regular-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Compact-Rounded";
font-style: normal;
font-weight: 600;
src:
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Semibold-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Semibold-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Semibold-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Compact-Rounded";
font-style: normal;
font-weight: 300;
src:
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Thin-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Thin-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Thin-Subset.otf")
format("opentype");
}
@font-face {
font-family: "SF-Compact-Rounded";
font-style: normal;
font-weight: 100;
src:
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Ultralight-Subset.woff2")
format("woff2"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Ultralight-Subset.woff")
format("woff"),
url("https://cdn.tombutcher.work/fonts/SF-Compact-Rounded-Ultralight-Subset.otf")
format("opentype");
}
p,
span,
h1,
h2,
h3,
h4,
input,
a,
strong {
margin-block-start: 0;
margin-block-end: 0;
font-family: "SF-Pro-Rounded" !important;
letter-spacing: 0.04em;
}
p,
span {
font-weight: 500;
}
h4 span {
font-size: 16px;
text-transform: uppercase;
}
h4 {
line-height: 1 !important;
margin-bottom: 0.5em !important;
}
h2 span,
h2 span * {
font-family: "Grold-Rounded-Slim" !important;
text-transform: uppercase;
font-weight: 700;
font-size: 40px;
line-height: 0.9px;
letter-spacing: 0.02em;
}
h1 span {
font-weight: 700;
}
h4 span {
font-weight: 700;
}
.ant-alert-message * {
font-family: "SF-Pro-Rounded";
letter-spacing: 0.04em;
font-weight: 500;
margin-bottom: 0px;
line-height: 20px;
}
.ant-alert-message span {
display: block;
}
.ant-steps-item-title {
line-height: 1.5 !important;
margin-bottom: 0.5em;
}
.ant-typography h3 {
margin-bottom: 0px;
}

0
src/login/icons.css Normal file
View File

View File

@ -0,0 +1,56 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "code.ftl" });
const meta = {
title: "login/code.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithErrorCode: Story = {
render: () => (
<KcPageStory
kcContext={{
code: {
success: false,
error: "Failed to generate code"
}
}}
/>
)
};
export const WithFrenchLanguage: Story = {
render: () => (
<KcPageStory
kcContext={{
locale: {
currentLanguageTag: "fr"
},
code: {
success: true,
code: "XYZ789"
}
}}
/>
)
};
export const WithHtmlErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
code: {
success: false,
error: "Something went wrong. <a href='https://example.com'>Try again</a>"
}
}}
/>
)
};

52
src/login/pages/Code.tsx Normal file
View File

@ -0,0 +1,52 @@
import { Typography, Alert, Space } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
const { Paragraph, Text } = Typography;
export default function Code(props: PageProps<Extract<KcContext, { pageId: "code.ftl" }>, I18n>): JSX.Element {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { code } = kcContext;
const { msg } = i18n;
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
headerNode={code.success ? msg("codeSuccessTitle") : msg("codeErrorTitle", code.error)}
>
{code.success ? (
<>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Space>
<Paragraph style={{ margin: 0 }}>{msg("copyCodeInstruction")}</Paragraph>
</Space>
<Text strong code style={{ fontSize: "18px" }}>
{code.code}
</Text>
</Space>
</>
) : (
code.error && (
<Alert
type="error"
showIcon
message={
<div
id="error"
dangerouslySetInnerHTML={{
__html: kcSanitize(code.error)
}}
/>
}
/>
)
)}
</Template>
);
}

View File

@ -0,0 +1,47 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "delete-account-confirm.ftl" });
const meta = {
title: "login/delete-account-confirm.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithAIAFlow: Story = {
render: () => (
<KcPageStory
kcContext={{
triggered_from_aia: true,
url: { loginAction: "/login-action" }
}}
/>
)
};
export const WithoutAIAFlow: Story = {
render: () => (
<KcPageStory
kcContext={{
triggered_from_aia: false,
url: { loginAction: "/login-action" }
}}
/>
)
};
export const WithCustomButtonStyle: Story = {
render: () => (
<KcPageStory
kcContext={{
triggered_from_aia: true,
url: { loginAction: "/login-action" }
}}
/>
)
};

View File

@ -0,0 +1,112 @@
import { Form, Button, Typography, Alert, Divider, List, Flex, FormProps } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { useState } from "react";
const { Text } = Typography;
export default function DeleteAccountConfirm(props: PageProps<Extract<KcContext, { pageId: "delete-account-confirm.ftl" }>, I18n>): JSX.Element {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const [form] = Form.useForm();
type FieldType = {
"cancel-aia": string;
};
const [isSubmitLoading, setIsSubmitLoading] = useState(false);
const [isCancelLoading, setIsCancelLoading] = useState(false);
const onFinish: FormProps<FieldType>["onFinish"] = async values => {
console.log("Submitting form:", values);
// Create a new form element
const form = document.createElement("form");
form.method = "POST";
form.action = url.loginAction; // URL where the form should be submitted
// Append input fields to the form
Object.entries(values).forEach(([key, value]) => {
const input = document.createElement("input");
input.type = "hidden"; // Hidden inputs for all form values
input.name = key;
input.value = value.toString();
form.appendChild(input);
});
// Append form to the body and submit
document.body.appendChild(form);
form.submit(); // Simulate actual form submission
};
const handleSubmit = (values: FieldType) => {
setIsSubmitLoading(true);
onFinish(values);
};
const handleCancel = () => {
setIsCancelLoading(true);
onFinish({ "cancel-aia": "true" });
};
const { url, triggered_from_aia } = kcContext;
const { msg, msgStr } = i18n;
return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={doUseDefaultCss} classes={classes} headerNode={msg("deleteAccountConfirm")}>
<Form form={form} layout="vertical" onFinish={handleSubmit} style={{ width: "100%" }}>
<Alert message={msg("irreversibleAction")} type="warning" showIcon style={{ marginBottom: 16 }} />
<Text>{msg("deletingImplies")}</Text>
<List
itemLayout="horizontal"
dataSource={[msg("loggingOutImmediately"), msg("errasingData")]}
renderItem={item => (
<List.Item style={{ padding: "8px 0" }}>
<Text type="secondary"> {item}</Text>
</List.Item>
)}
style={{ margin: "16px 0" }}
/>
<Text strong style={{ marginTop: 8 }}>
{msg("finalDeletionConfirmation")}
</Text>
<Divider />
<Flex gap={"middle"}>
<Button
type="primary"
style={{ flexGrow: 2 }}
danger
htmlType="submit"
size="large"
disabled={isSubmitLoading || isCancelLoading}
loading={isSubmitLoading}
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-bin.svg"}
width={14}
style={{ marginTop: "0px", marginBottom: "3px" }}
/>
}
>
{msgStr("doConfirmDelete")}
</Button>
{triggered_from_aia && (
<Button
size="large"
style={{ flexGrow: 1 }}
disabled={isSubmitLoading || isCancelLoading}
loading={isSubmitLoading}
onClick={handleCancel}
>
{msgStr("doCancel")}
</Button>
)}
</Flex>
</Form>
</Template>
);
}

View File

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "delete-credential.ftl" });
const meta = {
title: "login/delete-credential.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithCustomCredentialLabel: Story = {
render: () => (
<KcPageStory
kcContext={{
credentialLabel: "Test Credential",
url: { loginAction: "/login-action" }
}}
/>
)
};

View File

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

View File

@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "error.ftl" });
const meta = {
title: "login/error.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithAnotherMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "With another error message" }
}}
/>
)
};
export const WithHtmlErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "<strong>Error:</strong> Something went wrong. <a href='https://example.com'>Go back</a>"
}
}}
/>
)
};
export const FrenchError: Story = {
render: () => (
<KcPageStory
kcContext={{
locale: { currentLanguageTag: "fr" },
message: { summary: "Une erreur s'est produite" }
}}
/>
)
};
export const WithSkipLink: Story = {
render: () => (
<KcPageStory
kcContext={{
message: { summary: "An error occurred" },
skipLink: true,
client: {
baseUrl: "https://example.com"
}
}}
/>
)
};

36
src/login/pages/Error.tsx Normal file
View File

@ -0,0 +1,36 @@
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, Typography, Space, Divider } from "antd";
const { Title } = Typography;
export default function Error(props: PageProps<Extract<KcContext, { pageId: "error.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { message, client, skipLink } = kcContext;
const { msg } = i18n;
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={false}
headerNode={<Title level={2}>{msg("errorTitle")}</Title>}
>
<Space direction="vertical" size="middle">
<Alert message={<div dangerouslySetInnerHTML={{ __html: kcSanitize(message.summary) }} />} type="error" showIcon />
</Space>
{!skipLink && client !== undefined && client.baseUrl !== undefined && (
<>
<Divider />
<Button type="primary" id="backToApplication" size={"large"} block href={client.baseUrl}>
{msg("backToApplication")}
</Button>
</>
)}
</Template>
);
}

View File

@ -0,0 +1,28 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "frontchannel-logout.ftl" });
const meta = {
title: "login/frontchannel-logout.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithoutRedirectUrl: Story = {
render: () => (
<KcPageStory
kcContext={{
logout: {
clients: []
}
}}
/>
)
};

View File

@ -0,0 +1,50 @@
import { useEffect } from "react";
import { Button, List, Typography, Space } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
const { Title, Paragraph } = Typography;
export default function FrontchannelLogout(props: PageProps<Extract<KcContext, { pageId: "frontchannel-logout.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { logout } = kcContext;
const { msg, msgStr } = i18n;
useEffect(() => {
if (logout.logoutRedirectUri) {
window.location.replace(logout.logoutRedirectUri);
}
}, []);
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
documentTitle={msgStr("frontchannel-logout.title")}
headerNode={<Title level={3}>{msg("frontchannel-logout.title")}</Title>}
>
<Space direction="vertical" style={{ width: "100%" }}>
<Paragraph>{msg("frontchannel-logout.message")}</Paragraph>
<List
dataSource={logout.clients}
renderItem={client => (
<List.Item>
<Typography.Text>{client.name}</Typography.Text>
<iframe src={client.frontChannelLogoutUrl} style={{ display: "none" }} />
</List.Item>
)}
/>
{logout.logoutRedirectUri && (
<Button type="primary" id="continue" href={logout.logoutRedirectUri}>
{msg("doContinue")}
</Button>
)}
</Space>
</Template>
);
}

View File

@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "idp-review-user-profile.ftl" });
const meta = {
title: "login/idp-review-user-profile.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithFormValidationErrors: Story = {
render: () => (
<KcPageStory
kcContext={{
messagesPerField: {
existsError: (fieldName: string) => ["email", "firstName"].includes(fieldName),
get: (fieldName: string) => {
if (fieldName === "email") return "Invalid email format.";
if (fieldName === "firstName") return "First name is required.";
}
}
}}
/>
)
};
export const WithReadOnlyFields: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
email: { value: "jane.doe@example.com", readOnly: true },
firstName: { value: "Jane", readOnly: false }
}
}
}}
/>
)
};
export const WithPrefilledFormFields: Story = {
render: () => (
<KcPageStory
kcContext={{
profile: {
attributesByName: {
firstName: { value: "Jane" },
lastName: { value: "Doe" },
email: { value: "jane.doe@example.com" }
}
}
}}
/>
)
};

View File

@ -0,0 +1,53 @@
import { useState } from "react";
import { Form, Button, Space, Divider } from "antd";
import type { JSX } from "keycloakify/tools/JSX";
import type { LazyOrNot } from "keycloakify/tools/LazyOrNot";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { UserProfileFormFieldsProps } from "keycloakify/login/UserProfileFormFieldsProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { CheckOutlined } from "@ant-design/icons";
type IdpReviewUserProfileProps = PageProps<Extract<KcContext, { pageId: "idp-review-user-profile.ftl" }>, I18n> & {
UserProfileFormFields: LazyOrNot<(props: UserProfileFormFieldsProps) => JSX.Element>;
doMakeUserConfirmPassword: boolean;
};
export default function IdpReviewUserProfile(props: IdpReviewUserProfileProps) {
const { kcContext, i18n, doUseDefaultCss, Template, classes, UserProfileFormFields, doMakeUserConfirmPassword } = props;
const { msg, msgStr } = i18n;
const { url, messagesPerField } = kcContext;
const [isFormSubmittable, setIsFormSubmittable] = useState(false);
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
displayMessage={messagesPerField.exists("global")}
displayRequiredFields
headerNode={msg("loginIdpReviewProfileTitle")}
>
<Form id="kc-idp-review-profile-form" layout="vertical" method="post" action={url.loginAction} size="large">
<div className="kctbform" style={{ paddingBottom: 0 }}>
<UserProfileFormFields
kcContext={kcContext}
i18n={i18n}
onIsFormSubmittableValueChange={setIsFormSubmittable}
kcClsx={() => ""} // Placeholder for compatibility
doMakeUserConfirmPassword={doMakeUserConfirmPassword}
/>
</div>
<Divider />
<Space direction="vertical" style={{ width: "100%" }}>
<Form.Item>
<Button type="primary" htmlType="submit" icon={<CheckOutlined />} block size="large" disabled={!isFormSubmittable}>
{msgStr("doSubmit")}
</Button>
</Form.Item>
</Space>
</Form>
</Template>
);
}

View File

@ -0,0 +1,59 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "info.ftl" });
const meta = {
title: "login/info.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Message header",
message: {
summary: "Server info message"
}
}}
/>
)
};
export const WithLinkBack: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Message header",
message: {
summary: "Server message"
},
actionUri: undefined
}}
/>
)
};
export const WithRequiredActions: Story = {
render: () => (
<KcPageStory
kcContext={{
messageHeader: "Message header",
message: {
summary: "Required actions: "
},
requiredActions: ["CONFIGURE_TOTP", "UPDATE_PROFILE", "VERIFY_EMAIL", "CUSTOM_ACTION"],
"x-keycloakify": {
messages: {
"requiredAction.CUSTOM_ACTION": "Custom action"
}
}
}}
/>
)
};

82
src/login/pages/Info.tsx Normal file
View File

@ -0,0 +1,82 @@
import { Typography, Button, Space, Divider } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
const { Title, Text } = Typography;
export default function Info(props: PageProps<Extract<KcContext, { pageId: "info.ftl" }>, I18n>) {
const { kcContext, i18n, Template } = props;
const { advancedMsgStr, msg } = i18n;
const { messageHeader, message, requiredActions, skipLink, pageRedirectUri, actionUri, client } = kcContext;
// Prepare the message content with required actions if present
const getMessageContent = () => {
let html = message.summary;
if (requiredActions) {
html += "<b>";
html += requiredActions.map(requiredAction => advancedMsgStr(`requiredAction.${requiredAction}`)).join(", ");
html += "</b>";
}
return html;
};
// Determine which action link to show
const renderActionLink = () => {
if (skipLink) {
return null;
}
if (pageRedirectUri) {
return (
<>
<Divider />
<Button type="primary" block size={"large"} href={pageRedirectUri}>
{msg("backToApplication")}
</Button>
</>
);
}
if (actionUri) {
return (
<>
<Divider />
<Button type="primary" block size={"large"} href={actionUri}>
{msg("proceedWithAction")}
</Button>
</>
);
}
if (client.baseUrl) {
return (
<>
<Divider />
<Button type="primary" block size={"large"} href={client.baseUrl}>
{msg("backToApplication")}
</Button>
</>
);
}
return null;
};
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={false}
displayMessage={false}
headerNode={<Title level={3}>{messageHeader}</Title>}
>
<Space direction="vertical" size="middle">
<Text className="instruction">
<span dangerouslySetInnerHTML={{ __html: getMessageContent() }}></span>
</Text>
</Space>
{renderActionLink()}
</Template>
);
}

View File

@ -0,0 +1,360 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login.ftl" });
const meta = {
title: "login/login.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithInvalidCredential: Story = {
render: () => (
<KcPageStory
kcContext={{
login: {
username: "johndoe"
},
messagesPerField: {
// NOTE: The other functions of messagesPerField are derived from get() and
// existsError() so they are the only ones that need to mock.
existsError: (fieldName: string, ...otherFieldNames: string[]) => {
const fieldNames = [fieldName, ...otherFieldNames];
return fieldNames.includes("username") || fieldNames.includes("password");
},
get: (fieldName: string) => {
if (fieldName === "username" || fieldName === "password") {
return "Invalid username or password.";
}
return "";
}
}
}}
/>
)
};
export const WithoutRegistration: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { registrationAllowed: false }
}}
/>
)
};
export const WithoutRememberMe: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { rememberMe: false }
}}
/>
)
};
export const WithoutPasswordReset: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { resetPasswordAllowed: false }
}}
/>
)
};
export const WithEmailAsUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { loginWithEmailAllowed: false }
}}
/>
)
};
export const WithPresetUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
login: { username: "max.mustermann@mail.com" }
}}
/>
)
};
export const WithImmutablePresetUsername: Story = {
render: () => (
<KcPageStory
kcContext={{
auth: {
attemptedUsername: "max.mustermann@mail.com",
showUsername: true
},
usernameHidden: true,
message: {
type: "info",
summary: "Please re-authenticate to continue"
}
}}
/>
)
};
export const WithSocialProviders: Story = {
render: () => (
<KcPageStory
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
},
{
loginUrl: "facebook",
alias: "facebook",
providerId: "facebook",
displayName: "Facebook",
iconClasses: "fa fa-facebook"
},
{
loginUrl: "instagram",
alias: "instagram",
providerId: "instagram",
displayName: "Instagram",
iconClasses: "fa fa-instagram"
},
{
loginUrl: "twitter",
alias: "twitter",
providerId: "twitter",
displayName: "Twitter",
iconClasses: "fa fa-twitter"
},
{
loginUrl: "linkedin",
alias: "linkedin",
providerId: "linkedin",
displayName: "LinkedIn",
iconClasses: "fa fa-linkedin"
},
{
loginUrl: "stackoverflow",
alias: "stackoverflow",
providerId: "stackoverflow",
displayName: "Stackoverflow",
iconClasses: "fa fa-stack-overflow"
},
{
loginUrl: "github",
alias: "github",
providerId: "github",
displayName: "Github",
iconClasses: "fa fa-github"
},
{
loginUrl: "gitlab",
alias: "gitlab",
providerId: "gitlab",
displayName: "Gitlab",
iconClasses: "fa fa-gitlab"
},
{
loginUrl: "bitbucket",
alias: "bitbucket",
providerId: "bitbucket",
displayName: "Bitbucket",
iconClasses: "fa fa-bitbucket"
},
{
loginUrl: "paypal",
alias: "paypal",
providerId: "paypal",
displayName: "PayPal",
iconClasses: "fa fa-paypal"
},
{
loginUrl: "openshift",
alias: "openshift",
providerId: "openshift",
displayName: "OpenShift",
iconClasses: "fa fa-cloud"
}
]
}
}}
/>
)
};
export const WithoutPasswordField: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: { password: false }
}}
/>
)
};
export const WithErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
message: {
summary: "The time allotted for the connection has elapsed.<br/>The login process will restart from the beginning.",
type: "error"
}
}}
/>
)
};
export const WithOneSocialProvider: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
}
]
}
}}
/>
)
};
export const WithTwoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
}
]
}
}}
/>
)
};
export const WithNoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: []
}
}}
/>
)
};
export const WithMoreThanTwoSocialProviders: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
},
{
loginUrl: "microsoft",
alias: "microsoft",
providerId: "microsoft",
displayName: "Microsoft",
iconClasses: "fa fa-windows"
},
{
loginUrl: "facebook",
alias: "facebook",
providerId: "facebook",
displayName: "Facebook",
iconClasses: "fa fa-facebook"
},
{
loginUrl: "twitter",
alias: "twitter",
providerId: "twitter",
displayName: "Twitter",
iconClasses: "fa fa-twitter"
}
]
}
}}
/>
)
};
export const WithSocialProvidersAndWithoutRememberMe: Story = {
render: args => (
<KcPageStory
{...args}
kcContext={{
social: {
displayInfo: true,
providers: [
{
loginUrl: "google",
alias: "google",
providerId: "google",
displayName: "Google",
iconClasses: "fa fa-google"
}
]
},
realm: { rememberMe: false }
}}
/>
)
};

201
src/login/pages/Login.tsx Normal file
View File

@ -0,0 +1,201 @@
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Form, Input, Button, Checkbox, Space, Typography, Divider, Row, Col, FormProps } from "antd";
import { EyeTwoTone, EyeInvisibleOutlined } from "@ant-design/icons";
import * as FaIcons from "react-icons/fa6"; // Import all FA6 icons dynamically
import type { IconType } from "react-icons";
import { useState } from "react";
const { Title, Text, Link } = Typography;
export default function Login(props: PageProps<Extract<KcContext, { pageId: "login.ftl" }>, I18n>) {
const { kcContext, i18n, Template } = props;
type FieldType = {
string: string;
};
const [isLoading, setIsLoading] = useState(false);
const onFinish: FormProps<FieldType>["onFinish"] = async values => {
console.log("Submitting form:", values);
setIsLoading(true);
// Create a new form element
const form = document.createElement("form");
form.method = "POST";
form.action = url.loginAction; // URL where the form should be submitted
// Append input fields to the form
Object.entries(values).forEach(([key, value]) => {
const input = document.createElement("input");
input.type = "hidden"; // Hidden inputs for all form values
input.name = key;
input.value = value.toString();
form.appendChild(input);
});
// Append form to the body and submit
document.body.appendChild(form);
form.submit(); // Simulate actual form submission
};
const { social, realm, url, usernameHidden, login, auth, registrationDisabled, client } = kcContext;
const { msg, msgStr } = i18n;
const [form] = Form.useForm(); // Get the form instance
// Function to dynamically fetch the correct icon
const getIconComponent = (alias: string): IconType | undefined => {
const formattedAlias = alias
.split("-") // Convert kebab-case to PascalCase
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
const iconName = `Fa${formattedAlias}`; // Construct the react-icons name
return (FaIcons as Record<string, IconType>)[iconName];
};
const inputPrefix = !realm.loginWithEmailAllowed ? (
<img src={"https://cdn.tombutcher.work/icons/auth/c-at.svg"} width={14} style={{ marginRight: "3px" }} />
) : (
<img src={"https://cdn.tombutcher.work/icons/auth/c-person.svg"} width={14} style={{ marginRight: "3px" }} />
);
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={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("loginAccountTitle")}</Title>
</Space>
) : (
<Title level={3}>{msg("loginAccountTitle")}</Title>
)}
</>
}
displayInfo={realm.password && realm.registrationAllowed && !registrationDisabled}
infoNode={
<Space>
<Text>{msg("noAccount")}</Text>
<Link href={url.registrationUrl}>{msg("doRegister")}</Link>
</Space>
}
socialProvidersNode={
<>
{realm.password && social?.providers !== undefined && social.providers.length !== 0 && (
<div style={{ marginTop: 24 }}>
<Divider>
<Text type="secondary">{msg("identity-provider-login-label")}</Text>
</Divider>
<Space wrap style={{ width: "100%", justifyContent: "center" }}>
{social.providers.map(p => {
const IconComponent = getIconComponent(p.alias);
return (
<Button
key={p.alias}
id={`social-${p.alias}`}
href={p.loginUrl}
icon={IconComponent ? <IconComponent /> : undefined}
>
<span dangerouslySetInnerHTML={{ __html: kcSanitize(p.displayName) }} />
</Button>
);
})}
</Space>
</div>
)}
</>
}
>
{realm.password && (
<Form
form={form}
initialValues={{ username: login.username || "", rememberMe: !!login.rememberMe }}
layout="vertical"
requiredMark={false}
onFinish={onFinish}
>
<input type="hidden" name="credentialId" value={auth.selectedCredential} />
{!usernameHidden && (
<Form.Item
name="username"
label={
!realm.loginWithEmailAllowed
? msg("username")
: !realm.registrationEmailAsUsername
? msg("usernameOrEmail")
: msg("email")
}
>
<Input id="username" name="username" size="large" prefix={inputPrefix} autoFocus autoComplete="username" />
</Form.Item>
)}
<Form.Item name="password" label={msg("password")}>
<Input.Password
id="password"
name="password"
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-lock.svg"} style={{ marginRight: "3px" }} height={14} />}
autoComplete="current-password"
iconRender={visible => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
/>
</Form.Item>
<Row justify="space-between" align="middle">
{realm.rememberMe && !usernameHidden && (
<Col>
<Form.Item name="rememberMe" valuePropName="checked" noStyle>
<Checkbox id="rememberMe" name="rememberMe">
{msg("rememberMe")}
</Checkbox>
</Form.Item>
</Col>
)}
{realm.resetPasswordAllowed && (
<Col>
<Link href={url.loginResetCredentialsUrl}>{msg("doForgotPassword")}</Link>
</Col>
)}
</Row>
<Divider />
<Form.Item>
<Button
type="primary"
htmlType="submit"
id="kc-login"
name="login"
block
size="large"
disabled={isLoading}
loading={isLoading}
iconPosition={"end"}
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-right.svg"}
style={{ marginTop: "4px", marginBottom: 0 }}
height={14}
/>
}
>
{msgStr("doLogIn")}
</Button>
</Form.Item>
</Form>
)}
</Template>
);
}

View File

@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-config-totp.ftl" });
const meta = {
title: "login/login-config-totp.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
export const WithManualSetUp: Story = {
render: () => (
<KcPageStory
kcContext={{
mode: "manual"
}}
/>
)
};
export const WithError: Story = {
render: () => (
<KcPageStory
kcContext={{
messagesPerField: {
get: (fieldName: string) => (fieldName === "totp" ? "Invalid TOTP" : undefined),
exists: (fieldName: string) => fieldName === "totp",
existsError: (fieldName: string) => fieldName === "totp",
printIfExists: <T,>(fieldName: string, x: T) => (fieldName === "totp" ? x : undefined)
}
}}
/>
)
};
export const WithAppInitiatedAction: Story = {
render: () => (
<KcPageStory
kcContext={{
isAppInitiatedAction: true
}}
/>
)
};
export const WithPreFilledUserLabel: Story = {
render: () => (
<KcPageStory
kcContext={{
totp: {
otpCredentials: [{ userLabel: "MyDevice" }]
}
}}
/>
)
};

View File

@ -0,0 +1,295 @@
import { useState } from "react";
import { getKcClsx, KcClsx } from "keycloakify/login/lib/kcClsx";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Typography, List, Button, Form, Input, Checkbox, Space, Steps, Divider, Flex } from "antd";
const { Title, Text, Paragraph, Link } = Typography;
export default function LoginConfigTotp(props: PageProps<Extract<KcContext, { pageId: "login-config-totp.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { url, isAppInitiatedAction, totp, mode, messagesPerField } = kcContext;
const { msg, msgStr, advancedMsg } = i18n;
type FieldType = {
"cancel-aia": string;
};
const { kcClsx } = getKcClsx({
doUseDefaultCss,
classes
});
const [isSubmitLoading, setIsSubmitLoading] = useState(false);
const [isCancelLoading, setIsCancelLoading] = useState(false);
const onFinish = (values: Record<string, string>) => {
console.log("Submitting form:", values);
const form = document.createElement("form");
form.method = "POST";
form.action = url.loginAction;
Object.entries(values).forEach(([key, value]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value;
form.appendChild(input);
});
if (mode) {
const modeInput = document.createElement("input");
modeInput.type = "hidden";
modeInput.id = "mode";
modeInput.value = mode;
form.appendChild(modeInput);
}
const totpSecretInput = document.createElement("input");
totpSecretInput.type = "hidden";
totpSecretInput.name = "totpSecret";
totpSecretInput.value = totp.totpSecret;
form.appendChild(totpSecretInput);
document.body.appendChild(form);
form.submit();
};
const handleSubmit = (values: FieldType) => {
setIsSubmitLoading(true);
onFinish(values);
};
const handleCancel = () => {
setIsCancelLoading(true);
onFinish({ "cancel-aia": "true" });
};
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
headerNode={<Title level={3}>{msg("loginTotpTitle")}</Title>}
displayMessage={!messagesPerField.existsError("totp", "userLabel")}
>
<Steps
direction="vertical"
current={3}
items={[
{
title: msg("loginTotpStep1"),
icon: <img src={"https://cdn.tombutcher.work/icons/auth/c-download-128.svg"} height={14} />,
description: (
<div style={{ marginBottom: 24 }}>
<Paragraph>{msg("loginTotpStep1")}</Paragraph>
<List
id="kc-totp-supported-apps"
size="small"
bordered
dataSource={totp.supportedApplications}
renderItem={app => (
<List.Item>
<Space>
<img
src={"https://cdn.tombutcher.work/icons/auth/c-phone.svg"}
width={14}
style={{ marginRight: 8 }}
/>
{advancedMsg(app)}
</Space>
</List.Item>
)}
/>
</div>
)
},
{
title: mode === "manual" ? msg("loginTotpManualStep2") : msg("loginTotpStep2"),
icon: <img src={"https://cdn.tombutcher.work/icons/auth/c-qrcode-128.svg"} height={14} />,
description: (
<>
{mode === "manual" ? (
<div style={{ marginBottom: 24 }}>
<Paragraph>{msg("loginTotpManualStep2")}</Paragraph>
<Paragraph>
<Text code>{totp.totpSecretEncoded}</Text>
</Paragraph>
<Paragraph>
<Link href={totp.qrUrl} id="mode-barcode">
{msg("loginTotpScanBarcode")}
</Link>
</Paragraph>
<Divider />
<Paragraph>{msg("loginTotpManualStep3")}</Paragraph>
<List size="small" bordered>
<List.Item id="kc-totp-type">
<Text strong>{msg("loginTotpType")}:</Text> {msg(`loginTotp.${totp.policy.type}`)}
</List.Item>
<List.Item id="kc-totp-algorithm">
<Text strong>{msg("loginTotpAlgorithm")}:</Text> {totp.policy.getAlgorithmKey()}
</List.Item>
<List.Item id="kc-totp-digits">
<Text strong>{msg("loginTotpDigits")}:</Text> {totp.policy.digits}
</List.Item>
{totp.policy.type === "totp" ? (
<List.Item id="kc-totp-period">
<Text strong>{msg("loginTotpInterval")}:</Text> {totp.policy.period}
</List.Item>
) : (
<List.Item id="kc-totp-counter">
<Text strong>{msg("loginTotpCounter")}:</Text> {totp.policy.initialCounter}
</List.Item>
)}
</List>
</div>
) : (
<div style={{ marginBottom: 24 }}>
<Paragraph>{msg("loginTotpStep2")}</Paragraph>
<div style={{ textAlign: "center", margin: "20px 0" }}>
<img
id="kc-totp-secret-qr-code"
src={`data:image/png;base64, ${totp.totpSecretQrCode}`}
alt="QR Code"
style={{ maxWidth: "200px" }}
/>
</div>
<Paragraph>
<Link href={totp.manualUrl} id="mode-manual">
{msg("loginTotpUnableToScan")}
</Link>
</Paragraph>
</div>
)}
</>
)
},
{
title: msg("loginTotpStep3"),
icon: <img src={"https://cdn.tombutcher.work/icons/auth/c-check-128.svg"} height={14} />,
description: (
<div>
<Paragraph>{msg("loginTotpStep3")}</Paragraph>
<Text>{msg("loginTotpStep3DeviceName")}</Text>
</div>
)
}
]}
/>
<Divider />
<Form layout="vertical" onFinish={handleSubmit} style={{ margin: "0 auto" }}>
<Form.Item
label={
<>
{msg("authenticatorCode")} <span className="required">*</span>
</>
}
name="totp"
validateStatus={messagesPerField.existsError("totp") ? "error" : undefined}
help={
messagesPerField.existsError("totp") && (
<span
id="input-error-otp-code"
dangerouslySetInnerHTML={{
__html: kcSanitize(messagesPerField.get("totp"))
}}
/>
)
}
>
<Input
id="totp"
autoComplete="off"
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />}
aria-invalid={messagesPerField.existsError("totp")}
/>
</Form.Item>
<Form.Item
label={
<>
{msg("loginTotpDeviceName")} {totp.otpCredentials.length >= 1 && <span className="required">*</span>}
</>
}
name="userLabel"
validateStatus={messagesPerField.existsError("userLabel") ? "error" : undefined}
help={
messagesPerField.existsError("userLabel") && (
<span
id="input-error-otp-label"
dangerouslySetInnerHTML={{
__html: kcSanitize(messagesPerField.get("userLabel"))
}}
/>
)
}
>
<Input
id="userLabel"
autoComplete="off"
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-phone.svg"} width={14} style={{ marginRight: "3px" }} />}
aria-invalid={messagesPerField.existsError("userLabel")}
/>
</Form.Item>
<Form.Item>
<LogoutOtherSessions kcClsx={kcClsx} i18n={i18n} />
</Form.Item>
<Divider />
<Form.Item>
<Flex gap={"middle"}>
<Button
type="primary"
htmlType="submit"
style={{ flexGrow: 2 }}
size="large"
id="saveTOTPBtn"
iconPosition="end"
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
style={{ marginTop: "0px", marginBottom: "3px" }}
width={14}
/>
}
loading={isSubmitLoading}
disabled={isCancelLoading || isSubmitLoading}
>
{msgStr("doSubmit")}
</Button>
{isAppInitiatedAction ? (
<Button
type="default"
htmlType="submit"
size="large"
style={{ flexGrow: 1 }}
id="cancelTOTPBtn"
onClick={handleCancel}
loading={isCancelLoading}
disabled={isCancelLoading || isSubmitLoading}
>
{msg("doCancel")}
</Button>
) : null}
</Flex>
</Form.Item>
</Form>
</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

@ -0,0 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
// Mock kcContext to avoid the TS2304 error
const mockKcContext = {
url: {
loginAction: "/login-action"
},
idpAlias: "mockIdpAlias"
};
const { KcPageStory } = createKcPageStory({ pageId: "login-idp-link-confirm.ftl" });
const meta = {
title: "login/login-idp-link-confirm.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Default:
* - Purpose: Tests standard behavior with mock data.
* - Scenario: The component renders with a mocked identity provider alias (`mockIdpAlias`) and a login action URL (`/login-action`).
* - Key Aspect: Ensures the default behavior of the component with standard values for kcContext.
*/
export const Default: Story = {
render: () => <KcPageStory kcContext={mockKcContext} />
};
/**
* WithFormSubmissionError:
* - Purpose: Tests how the component handles form submission errors.
* - Scenario: Simulates a form submission error by setting the login action URL to `/error` and displays an error message.
* - Key Aspect: Verifies that the component can display error messages during form submission failure, ensuring proper error handling.
*/
export const WithFormSubmissionError: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
url: {
loginAction: "/error"
},
message: {
type: "error",
summary: "An error occurred during form submission."
}
}}
/>
)
};

View File

@ -0,0 +1,29 @@
import { Button, Divider, Form, Space } from "antd";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function LoginIdpLinkConfirm(props: PageProps<Extract<KcContext, { pageId: "login-idp-link-confirm.ftl" }>, I18n>) {
const { kcContext, i18n, Template } = props;
const { url, idpAlias } = kcContext;
const { msg } = i18n;
return (
<Template kcContext={kcContext} i18n={i18n} doUseDefaultCss={false} classes={{}} headerNode={msg("confirmLinkIdpTitle")}>
<Divider />
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Form id="kc-register-form" action={url.loginAction} method="post">
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Button type="default" htmlType="submit" id="updateProfile" name="submitAction" value="updateProfile" block size="large">
{msg("confirmLinkIdpReviewProfile")}
</Button>
<Button type="primary" htmlType="submit" id="linkAccount" name="submitAction" value="linkAccount" block size="large">
{msg("confirmLinkIdpContinue", idpAlias)}
</Button>
</Space>
</Form>
</Space>
</Template>
);
}

View File

@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-idp-link-confirm-override.ftl" });
const meta = {
title: "login/login-idp-link-confirm-override.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};

View File

@ -0,0 +1,106 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
// Mock kcContext to avoid TS2304 error and to simulate the real environment
const mockKcContext = {
url: {
loginAction: "/login-action"
},
idpAlias: "mockIdpAlias",
brokerContext: {
username: "mockUser"
},
realm: {
displayName: "MockRealm"
}
};
const { KcPageStory } = createKcPageStory({ pageId: "login-idp-link-email.ftl" });
const meta = {
title: "login/login-idp-link-email.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Default:
* - Purpose: Tests the default behavior with mock data.
* - Scenario: The component renders with a mocked identity provider alias (`mockIdpAlias`), a default broker username (`mockUser`), and a default realm name (`MockRealm`).
* - Key Aspect: Ensures the default behavior of the component with typical kcContext values.
*/
export const Default: Story = {
render: () => <KcPageStory kcContext={mockKcContext} />
};
/**
* WithIdpAlias:
* - Purpose: Tests behavior when the idpAlias is set to "Google".
* - Scenario: Simulates the component being used with a Google identity provider, showing the username "john.doe" and realm "MyRealm".
* - Key Aspect: Ensures the correct identity provider alias ("Google") and broker context (user info) are displayed in the email linking instructions.
*/
export const WithIdpAlias: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
idpAlias: "Google",
brokerContext: {
username: "john.doe"
},
realm: {
displayName: "MyRealm"
}
}}
/>
)
};
/**
* WithCustomRealmDisplayName:
* - Purpose: Tests behavior when the realm display name is customized.
* - Scenario: Simulates the component with a Facebook identity provider, a broker username "jane.doe", and a custom realm name "CustomRealm".
* - Key Aspect: Ensures that custom realm display names are rendered correctly alongside the idpAlias and broker context.
*/
export const WithCustomRealmDisplayName: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
idpAlias: "Facebook",
brokerContext: {
username: "jane.doe"
},
realm: {
displayName: "CUSTOM REALM DISPLAY NAME"
}
}}
/>
)
};
/**
* WithFormSubmissionError:
* - Purpose: Tests how the component handles form submission errors.
* - Scenario: Simulates a form submission error by setting the login action URL to `/error` and displays an error message.
* - Key Aspect: Verifies that the component can display error messages during form submission failure, ensuring proper error handling.
*/
export const WithFormSubmissionError: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
url: {
loginAction: "/error"
},
message: {
type: "error",
summary: "An error occurred during form submission."
}
}}
/>
)
};

View File

@ -0,0 +1,49 @@
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Typography, Space, Button } from "antd";
const { Title, Text } = Typography;
export default function LoginIdpLinkEmail(props: PageProps<Extract<KcContext, { pageId: "login-idp-link-email.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, Template, classes } = props;
const { url, realm, brokerContext, idpAlias } = kcContext;
const { msg } = i18n;
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
headerNode={
<Space align="center">
<Title level={2} style={{ margin: 0 }}>
{msg("emailLinkIdpTitle", idpAlias)}
</Title>
</Space>
}
>
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Text id="instruction1" className="instruction">
<span style={{ fontWeight: "800" }}>{msg("emailLinkIdp1", idpAlias, brokerContext.username, realm.displayName)}</span>
</Text>
<Text id="instruction2" className="instruction">
{msg("emailLinkIdp2")}{" "}
<Button type="link" href={url.loginAction} style={{ padding: 0 }}>
{msg("doClickHere")}
</Button>{" "}
{msg("emailLinkIdp3")}
</Text>
<Text id="instruction3" className="instruction">
{msg("emailLinkIdp4")}{" "}
<Button type="link" href={url.loginAction} style={{ padding: 0 }}>
{msg("doClickHere")}
</Button>{" "}
{msg("emailLinkIdp5")}
</Text>
</Space>
</Template>
);
}

View File

@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-oauth2-device-verify-user-code.ftl" });
const meta = {
title: "login/login-oauth2-device-verify-user-code.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithErrorMessage:
* - Purpose: Tests when there is an error with the OAuth2 device user code entry.
* - Scenario: The component renders with an error message displayed to the user.
* - Key Aspect: Ensures the error message is properly shown when the user enters an invalid code.
*/
export const WithErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
oauth2DeviceVerificationAction: "/mock-oauth2-device-verification"
},
message: {
summary: "The user code you entered is invalid. Please try again.",
type: "error"
}
}}
/>
)
};
/**
* WithEmptyInputField:
* - Purpose: Tests when the user code field is left empty.
* - Scenario: The component renders the form, and the user tries to submit without entering any code.
* - Key Aspect: Ensures the form displays validation errors when the field is left empty.
*/
export const WithEmptyInputField: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
oauth2DeviceVerificationAction: "/mock-oauth2-device-verification"
},
message: {
summary: "User code cannot be empty. Please enter a valid code.",
type: "error"
}
}}
/>
)
};

View File

@ -0,0 +1,80 @@
import { useState } from "react";
import { Button, Divider, Form, Input } from "antd";
import { PageProps } from "keycloakify/login/pages/PageProps";
import { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
export default function LoginOauth2DeviceVerifyUserCode(
props: PageProps<Extract<KcContext, { pageId: "login-oauth2-device-verify-user-code.ftl" }>, I18n>
) {
const { kcContext, i18n, Template } = props;
const { url } = kcContext;
const { msg, msgStr } = i18n;
type FieldType = {
"cancel-aia": string;
};
const [isSubmitLoading, setIsSubmitLoading] = useState(false);
const onFinish = (values: Record<string, string>) => {
console.log("Submitting form:", values);
const form = document.createElement("form");
form.method = "POST";
form.action = url.oauth2DeviceVerificationAction;
Object.entries(values).forEach(([key, value]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
};
const handleSubmit = (values: FieldType) => {
setIsSubmitLoading(true);
onFinish(values);
};
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}>
<Form.Item label={msg("verifyOAuth2DeviceUserCode")} name="device_user_code" rules={[{ required: true, message: "Required" }]}>
<Input
id="device-user-code"
name="device_user_code"
autoComplete="off"
autoFocus
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-hash.svg"} width={14} style={{ marginRight: "3px" }} />}
/>
</Form.Item>
<Divider />
<Form.Item>
<Button
type="primary"
htmlType="submit"
block
size="large"
iconPosition="end"
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
style={{ marginTop: "3px", marginBottom: "0px" }}
width={14}
/>
}
loading={isSubmitLoading}
disabled={isSubmitLoading}
>
{msgStr("doSubmit")}
</Button>
</Form.Item>
</Form>
</Template>
);
}

View File

@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
// Mock kcContext to simulate real environment
const mockKcContext = {
url: {
oauthAction: "/oauth-action"
},
oauth: {
clientScopesRequested: [{ consentScreenText: "Scope1", dynamicScopeParameter: "dynamicScope1" }, { consentScreenText: "Scope2" }],
code: "mockCode"
},
client: {
attributes: {
logoUri: "https://cdn.tombutcher.work/logos/grafana-logo.png",
policyUri: "https://twitter.com/en/tos",
tosUri: "https://twitter.com/en/privacy"
},
name: "Twitter",
clientId: "twitter-client-id"
}
};
const { KcPageStory } = createKcPageStory({ pageId: "login-oauth-grant.ftl" });
const meta = {
title: "login/login-oauth-grant.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
/**
* Default:
* - Purpose: Tests the default behavior with meaningful logo (Twitter).
* - Scenario: The component renders with Twitter as the client, displaying its logo, policy, and terms of service links.
* - Key Aspect: Ensures the component works with a realistic `logoUri` and client name.
*/
export const Default: Story = {
render: () => <KcPageStory kcContext={mockKcContext} />
};
/**
* WithoutScopes:
* - Purpose: Tests the component when no OAuth scopes are requested.
* - Scenario: The component renders with no scopes listed under the consent screen.
* - Key Aspect: Ensures the component renders correctly when there are no requested scopes.
*/
export const WithoutScopes: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
oauth: {
...mockKcContext.oauth,
clientScopesRequested: []
}
}}
/>
)
};
/**
* WithFormSubmissionError:
* - Purpose: Tests how the component handles form submission errors.
* - Scenario: The `oauthAction` URL is set to an error route and an error message is displayed.
* - Key Aspect: Ensures that the component can display error messages when form submission fails.
*/
export const WithFormSubmissionError: Story = {
render: () => (
<KcPageStory
kcContext={{
...mockKcContext,
url: {
oauthAction: "/error"
},
message: {
type: "error",
summary: "An error occurred during form submission."
}
}}
/>
)
};

View File

@ -0,0 +1,170 @@
import { useState } from "react";
import { PageProps } from "keycloakify/login/pages/PageProps";
import { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
import { Typography, Space, List, Button, Flex, Divider, Alert } from "antd";
const { Title, Text, Link } = Typography;
export default function LoginOauthGrant(props: PageProps<Extract<KcContext, { pageId: "login-oauth-grant.ftl" }>, I18n>) {
const { kcContext, i18n, doUseDefaultCss, classes, Template } = props;
const { url, oauth, client } = kcContext;
const { msg, msgStr, advancedMsg, advancedMsgStr } = i18n;
const [isAcceptLoading, setIsAcceptLoading] = useState(false);
const [isDeclineLoading, setIsDeclineLoading] = useState(false);
const handleSubmit = (action: "accept" | "cancel") => {
// Create a new form element
const form = document.createElement("form");
form.method = "POST";
form.action = url.oauthAction;
// Add the code value
const codeInput = document.createElement("input");
codeInput.type = "hidden";
codeInput.name = "code";
codeInput.value = oauth.code;
form.appendChild(codeInput);
// Add the action value
const actionInput = document.createElement("input");
actionInput.type = "hidden";
actionInput.name = action;
actionInput.value = action === "accept" ? msgStr("doYes") : msgStr("doNo");
form.appendChild(actionInput);
// Append form to the body and submit
document.body.appendChild(form);
form.submit();
};
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={doUseDefaultCss}
classes={classes}
bodyClassName="oauth"
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}>
{client.name ? msg("oauthGrantTitle", advancedMsgStr(client.name)) : msg("oauthGrantTitle", client.clientId)}
</Title>
</Space>
) : (
<Title level={3}>
{client.name ? msg("oauthGrantTitle", advancedMsgStr(client.name)) : msg("oauthGrantTitle", client.clientId)}
</Title>
)}
</>
}
>
<Alert message={msg("oauthGrantRequest")} type="warning" style={{ marginBottom: "20px" }} showIcon></Alert>
{(client.attributes.policyUri || client.attributes.tosUri) && (
<Alert
message={client.name ? msg("oauthGrantInformation", advancedMsgStr(client.name)) : msg("oauthGrantInformation", client.clientId)}
type="info"
style={{ marginBottom: "20px" }}
showIcon
></Alert>
)}
<List
dataSource={oauth.clientScopesRequested}
bordered={true}
renderItem={clientScope => (
<List.Item key={clientScope.consentScreenText}>
<Space>
<img
src={"https://cdn.tombutcher.work/icons/auth/c-check-128.svg"}
style={{ marginTop: 0, marginBottom: "3px" }}
width={14}
/>
<Text>
{advancedMsg(clientScope.consentScreenText)}
{clientScope.dynamicScopeParameter && (
<>
: <Text strong>{clientScope.dynamicScopeParameter}</Text>
</>
)}
</Text>
</Space>
</List.Item>
)}
/>
{(client.attributes.policyUri || client.attributes.tosUri) && (
<>
<Space direction="vertical" style={{ marginTop: "12px" }}>
{client.attributes.tosUri && (
<Text>
{msg("oauthGrantReview")}{" "}
<Link href={client.attributes.tosUri} target="_blank">
{msg("oauthGrantTos")}
</Link>
</Text>
)}
{client.attributes.policyUri && (
<Text>
{msg("oauthGrantReview")}{" "}
<Link href={client.attributes.policyUri} target="_blank">
{msg("oauthGrantPolicy")}
</Link>
</Text>
)}
</Space>
</>
)}
<Divider />
<Flex gap="middle">
<Button
type="primary"
style={{ flexGrow: 1 }}
size="large"
id="kc-login"
loading={isAcceptLoading}
disabled={isAcceptLoading || isDeclineLoading}
iconPosition="end"
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-checkmark.svg"}
style={{ marginTop: "0px", marginBottom: "3px" }}
width={14}
/>
}
onClick={() => {
setIsAcceptLoading(true);
handleSubmit("accept");
}}
>
{msgStr("doYes")}
</Button>
<Button
type="default"
style={{ flexGrow: 1 }}
size="large"
id="kc-cancel"
loading={isDeclineLoading}
disabled={isAcceptLoading || isDeclineLoading}
onClick={() => {
setIsDeclineLoading(true);
handleSubmit("cancel");
}}
>
{msgStr("doNo")}
</Button>
</Flex>
</Template>
);
}

View File

@ -0,0 +1,127 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-otp.ftl" });
const meta = {
title: "login/login-otp.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* MultipleOtpCredentials:
* - Purpose: Tests the behavior when the user has multiple OTP credentials to choose from.
* - Scenario: Simulates the scenario where the user is presented with multiple OTP credentials and must select one to proceed.
* - Key Aspect: Ensures that multiple OTP credentials are listed and selectable, and the correct credential is selected by default.
*/
export const MultipleOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: [
{ id: "credential1", userLabel: "Device 1" },
{ id: "credential2", userLabel: "Device 2" },
{ id: "credential2", userLabel: "Device 3" },
{ id: "credential2", userLabel: "Device 4" },
{ id: "credential2", userLabel: "Device 5" },
{ id: "credential2", userLabel: "Device 6" }
],
selectedCredentialId: "credential1"
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};
/**
* WithOtpError:
* - Purpose: Tests the behavior when an error occurs with the OTP field (e.g., invalid OTP code).
* - Scenario: Simulates an invalid OTP code scenario where an error message is displayed.
* - Key Aspect: Ensures that the OTP input displays error messages correctly and the error is visible.
*/
export const WithOtpError: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: []
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: (field: string) => field === "totp",
get: () => "Invalid OTP code"
}
}}
/>
)
};
/**
* NoOtpCredentials:
* - Purpose: Tests the behavior when no OTP credentials are provided for the user.
* - Scenario: Simulates the scenario where the user is not presented with any OTP credentials, and only the OTP input is displayed.
* - Key Aspect: Ensures that the component handles cases where there are no user OTP credentials, and the user is only prompted for the OTP code.
*/
export const NoOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: []
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};
/**
* WithErrorAndMultipleOtpCredentials:
* - Purpose: Tests behavior when there is both an error in the OTP field and multiple OTP credentials.
* - Scenario: Simulates the case where the user has multiple OTP credentials and encounters an error with the OTP input.
* - Key Aspect: Ensures that the component can handle both multiple OTP credentials and display an error message simultaneously.
*/
export const WithErrorAndMultipleOtpCredentials: Story = {
render: () => (
<KcPageStory
kcContext={{
otpLogin: {
userOtpCredentials: [
{ id: "credential1", userLabel: "Device 1" },
{ id: "credential2", userLabel: "Device 2" }
],
selectedCredentialId: "credential1"
},
url: {
loginAction: "/login-action"
},
messagesPerField: {
existsError: (field: string) => field === "totp",
get: () => "Invalid OTP code"
}
}}
/>
)
};

View File

@ -0,0 +1,126 @@
import { useState } from "react";
import { Form, Input, Button, Radio, List, Typography, Space, Divider } from "antd";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
const { Text } = Typography;
export default function LoginOtp(props: PageProps<Extract<KcContext, { pageId: "login-otp.ftl" }>, I18n>) {
const { kcContext, i18n, Template } = props;
const { otpLogin, url, messagesPerField } = kcContext;
const { msg, msgStr } = i18n;
type FieldType = {
"cancel-aia": string;
};
const [isSubmitLoading, setIsSubmitLoading] = useState(false);
const onFinish = (values: Record<string, string>) => {
console.log("Submitting form:", values);
const form = document.createElement("form");
form.method = "POST";
form.action = url.loginAction;
Object.entries(values).forEach(([key, value]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value;
form.appendChild(input);
});
document.body.appendChild(form);
form.submit();
};
const handleSubmit = (values: FieldType) => {
setIsSubmitLoading(true);
onFinish(values);
};
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={false}
classes={{}}
displayMessage={!messagesPerField.existsError("totp")}
headerNode={msg("doLogIn")}
>
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Form id="kc-otp-login-form" layout="vertical" onFinish={handleSubmit}>
{otpLogin.userOtpCredentials.length > 1 && (
<Form.Item name="selectedCredentialId">
<Radio.Group defaultValue={otpLogin.selectedCredentialId} style={{ width: "100%" }}>
<List
dataSource={otpLogin.userOtpCredentials}
bordered
style={{ width: "100%", lineHeight: 1 }}
renderItem={(otpCredential, index) => (
<List.Item key={index}>
<Radio id={`kc-otp-credential-${index}`} value={otpCredential.id} style={{ display: "block" }}>
<Text style={{ position: "relative", top: "-3px" }}>{otpCredential.userLabel}</Text>
</Radio>
</List.Item>
)}
/>
</Radio.Group>
</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" }} />}
/>
</Form.Item>
<Divider />
<Form.Item>
<Button
type="primary"
htmlType="submit"
id="kc-login"
name="login"
block
size="large"
disabled={isSubmitLoading}
loading={isSubmitLoading}
iconPosition={"end"}
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-right.svg"}
style={{ marginTop: "4px", marginBottom: 0 }}
height={14}
/>
}
>
{msgStr("doLogIn")}
</Button>
</Form.Item>
</Form>
</Space>
</Template>
);
}

View File

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-page-expired.ftl" });
const meta = {
title: "login/login-page-expired.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithErrorMessage:
* - Purpose: Tests behavior when an error message is displayed along with the page expiration message.
* - Scenario: Simulates a case where the session expired due to an error, and an error message is displayed alongside the expiration message.
* - Key Aspect: Ensures that error messages are displayed correctly in addition to the page expiration notice.
*/
export const WithErrorMessage: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginRestartFlowUrl: "/mock-restart-flow",
loginAction: "/mock-continue-login"
},
message: {
type: "error",
summary: "An error occurred while processing your session."
}
}}
/>
)
};

View File

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

View File

@ -0,0 +1,17 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-passkeys-conditional-authenticate.ftl" });
const meta = {
title: "login/login-passkeys-conditional-authenticate.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};

View File

@ -0,0 +1,68 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-password.ftl" });
const meta = {
title: "login/login-password.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithPasswordError:
* - Purpose: Tests the behavior when an error occurs in the password field (e.g., incorrect password).
* - Scenario: Simulates a scenario where an invalid password is entered, and an error message is displayed.
* - Key Aspect: Ensures that the password input field displays error messages correctly.
*/
export const WithPasswordError: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
resetPasswordAllowed: true
},
url: {
loginAction: "/mock-login",
loginResetCredentialsUrl: "/mock-reset-password"
},
messagesPerField: {
existsError: (field: string) => field === "password",
get: () => "Invalid password"
}
}}
/>
)
};
/**
* WithoutResetPasswordOption:
* - Purpose: Tests the behavior when the reset password option is disabled.
* - Scenario: Simulates a scenario where the `resetPasswordAllowed` is set to `false`, and the "Forgot Password" link is not rendered.
* - Key Aspect: Ensures that the component handles cases where resetting the password is not allowed.
*/
export const WithoutResetPasswordOption: Story = {
render: () => (
<KcPageStory
kcContext={{
realm: {
resetPasswordAllowed: false
},
url: {
loginAction: "/mock-login",
loginResetCredentialsUrl: "/mock-reset-password"
},
messagesPerField: {
existsError: () => false
}
}}
/>
)
};

View File

@ -0,0 +1,127 @@
import { useState } from "react";
import { Form, Input, Button, Divider, Typography, Space, FormProps } from "antd";
import { EyeTwoTone, EyeInvisibleOutlined } from "@ant-design/icons";
import { kcSanitize } from "keycloakify/lib/kcSanitize";
import type { PageProps } from "keycloakify/login/pages/PageProps";
import type { KcContext } from "../KcContext";
import type { I18n } from "../i18n";
const { Title, Link } = Typography;
export default function LoginPassword(props: PageProps<Extract<KcContext, { pageId: "login-password.ftl" }>, I18n>) {
const { kcContext, i18n, Template } = props;
const [isLoading, setIsLoading] = useState(false);
const [form] = Form.useForm();
const { realm, url, messagesPerField, client } = kcContext;
const { msg, msgStr } = i18n;
type FieldType = {
string: string;
};
const onFinish: FormProps<FieldType>["onFinish"] = async values => {
setIsLoading(true);
// Create a new form element
const form = document.createElement("form");
form.method = "POST";
form.action = url.loginAction;
// Append input fields to the form
Object.entries(values).forEach(([key, value]) => {
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value.toString();
form.appendChild(input);
});
// Append form to the body and submit
document.body.appendChild(form);
form.submit();
};
return (
<Template
kcContext={kcContext}
i18n={i18n}
doUseDefaultCss={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("loginAccountTitle")}</Title>
</Space>
) : (
<Title level={3}>{msg("loginAccountTitle")}</Title>
)}
</>
}
displayMessage={!messagesPerField.existsError("password")}
>
<Form form={form} layout="vertical" requiredMark={false} onFinish={onFinish}>
<Form.Item
name="password"
label={msg("password")}
validateStatus={messagesPerField.existsError("password") ? "error" : ""}
help={
messagesPerField.existsError("password") && (
<span
dangerouslySetInnerHTML={{
__html: kcSanitize(messagesPerField.get("password"))
}}
/>
)
}
>
<Input.Password
id="password"
name="password"
size="large"
prefix={<img src={"https://cdn.tombutcher.work/icons/auth/c-lock.svg"} style={{ marginRight: "3px" }} height={14} />}
autoFocus
autoComplete="current-password"
iconRender={visible => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
/>
</Form.Item>
{realm.resetPasswordAllowed && (
<div style={{ textAlign: "right", marginBottom: 16 }}>
<Link href={url.loginResetCredentialsUrl}>{msg("doForgotPassword")}</Link>
</div>
)}
<Divider />
<Form.Item>
<Button
type="primary"
htmlType="submit"
id="kc-login"
name="login"
block
size="large"
disabled={isLoading}
loading={isLoading}
iconPosition="end"
icon={
<img
src={"https://cdn.tombutcher.work/icons/auth/w-right.svg"}
style={{ marginTop: "4px", marginBottom: 0 }}
height={14}
/>
}
>
{msgStr("doLogIn")}
</Button>
</Form.Item>
</Form>
</Template>
);
}

View File

@ -0,0 +1,39 @@
import type { Meta, StoryObj } from "@storybook/react";
import { createKcPageStory } from "../KcPageStory";
const { KcPageStory } = createKcPageStory({ pageId: "login-recovery-authn-code-config.ftl" });
const meta = {
title: "login/login-recovery-authn-code-config.ftl",
component: KcPageStory
} satisfies Meta<typeof KcPageStory>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => <KcPageStory />
};
/**
* WithErrorDuringCodeGeneration:
* - Purpose: Tests when an error occurs while generating recovery authentication codes.
* - Scenario: The component renders an error message to inform the user of the failure during code generation.
* - Key Aspect: Ensures that error messages are properly displayed when recovery code generation fails.
*/
export const WithErrorDuringCodeGeneration: Story = {
render: () => (
<KcPageStory
kcContext={{
url: {
loginAction: "/mock-login-action"
},
message: {
summary: "An error occurred during recovery code generation. Please try again.",
type: "error"
}
}}
/>
)
};

View File

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

Some files were not shown because too many files have changed in this diff Show More