mirror of https://github.com/halo-dev/halo
				
				
				
			Add frontend support for customizing login and logout pages
Signed-off-by: JohnNiang <johnniang@foxmail.com>pull/6488/head
							parent
							
								
									1f9610686b
								
							
						
					
					
						commit
						8547ffe613
					
				
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 11 KiB  | 
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
<svg  clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" viewBox="0 0 2144 877" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="mb-8 flex-none"><linearGradient id="uicons-hepky0qzdn" gradientTransform="matrix(0 -848.921 848.921 0 1308.8 875.397)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#0050b5"></stop><stop offset="1" stop-color="#0b87fd"></stop></linearGradient><linearGradient id="uicons-9a00r6vh1r" gradientTransform="matrix(0 472.459 -473.895 0 587.619 -.861651)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#0048af"></stop><stop offset="1" stop-color="#003580"></stop></linearGradient><linearGradient id="uicons-payvanzieq" gradientTransform="matrix(0 898.506 -901.236 0 162.421 -12.1337)" gradientUnits="userSpaceOnUse" x1="0" x2="1" y1="0" y2="0"><stop offset="0" stop-color="#0b89ff"></stop><stop offset="1" stop-color="#004eb2"></stop></linearGradient><g fill="url(#uicons-hepky0qzdn)"><path d="m1028.16 339.331c148.249 0 268.609 120.36 268.609 268.609s-120.36 268.608-268.609 268.608-268.608-120.359-268.608-268.608 120.359-268.609 268.608-268.609zm0 119.152c82.488 0 149.457 66.969 149.457 149.457 0 82.487-66.969 149.456-149.457 149.456-82.487 0-149.456-66.969-149.456-149.456 0-82.488 66.969-149.457 149.456-149.457z"></path><path d="m1874.58 339.331c148.249 0 268.608 120.36 268.608 268.609s-120.359 268.608-268.608 268.608-268.609-120.359-268.609-268.608 120.36-268.609 268.609-268.609zm0 119.152c82.487 0 149.456 66.969 149.456 149.457 0 82.487-66.969 149.456-149.456 149.456-82.488 0-149.457-66.969-149.457-149.456 0-82.488 66.969-149.457 149.457-149.457z"></path><path d="m1309.27 377.585c0-10.083-7.222-18.719-17.146-20.504-19.618-3.528-51.9-9.334-74.172-13.34-6.073-1.092-12.318.564-17.052 4.522-4.734 3.959-7.469 9.812-7.469 15.983v491.469c0 5.525 2.195 10.824 6.102 14.731s9.206 6.102 14.731 6.102h74.173c5.525 0 10.824-2.195 14.731-6.102s6.102-9.206 6.102-14.731c0-84.425 0-400.286 0-478.13z"></path><path d="m1542.59 72.033c0-8.288-3.292-16.237-9.153-22.097-5.86-5.861-13.809-9.153-22.097-9.153-23.867 0-56.609 0-80.477 0-8.288 0-16.236 3.292-22.097 9.153-5.86 5.86-9.153 13.809-9.153 22.097v773.265c0 8.288 3.293 16.237 9.153 22.097 5.861 5.861 13.809 9.153 22.097 9.153h80.477c8.288 0 16.237-3.292 22.097-9.153 5.861-5.86 9.153-13.809 9.153-22.097 0-131.79 0-641.475 0-773.265z" fill-rule="nonzero"></path></g><path d="m506.409 822.063c0 13.815 5.494 27.062 15.271 36.821 9.777 9.76 23.034 15.23 36.848 15.206 18.674-.034 39.711-.072 58.369-.105 28.696-.052 51.932-23.329 51.932-52.026v-769.586c0-13.798-5.481-27.031-15.238-36.788-9.756-9.757-22.99-15.238-36.788-15.238h-58.368c-13.798 0-27.031 5.481-36.788 15.238s-15.238 22.99-15.238 36.788z" fill="url(#uicons-9a00r6vh1r)" fill-rule="nonzero"></path><path d="m616.746 322.662c13.813 0 27.061 5.487 36.829 15.255 9.767 9.768 15.254 23.015 15.254 36.829v447.062c0 13.814-5.487 27.061-15.254 36.829-9.768 9.767-23.016 15.255-36.829 15.255-18.632 0-39.622 0-58.254 0-13.813 0-27.061-5.488-36.828-15.255-9.768-9.768-15.255-23.015-15.255-36.829 0-68.223 0-187.159 0-255.383 0-13.813-5.487-27.061-15.255-36.828-9.767-9.768-23.015-15.255-36.828-15.255-129.249 0-454.326 0-454.326 0v-191.68z" fill="#0051b0" fill-rule="nonzero"></path><path d="m0 822.101c0 13.817 5.497 27.067 15.277 36.827 9.781 9.76 23.043 15.229 36.86 15.199 18.675-.04 39.713-.085 58.368-.124 28.69-.062 51.916-23.337 51.916-52.027 0-155.205 0-614.509 0-769.714 0-28.69-23.226-51.965-51.916-52.026-18.655-.04-39.693-.085-58.368-.125-13.817-.029-27.079 5.439-36.86 15.199-9.78 9.76-15.277 23.01-15.277 36.827v769.964z" fill="url(#uicons-payvanzieq)" fill-rule="nonzero"></path></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 3.7 KiB  | 
| 
						 | 
				
			
			@ -0,0 +1,154 @@
 | 
			
		|||
const Toast = (function () {
 | 
			
		||||
    let container;
 | 
			
		||||
 | 
			
		||||
    function getContainer() {
 | 
			
		||||
        if (container) return container;
 | 
			
		||||
 | 
			
		||||
        container = document.createElement("div");
 | 
			
		||||
        container.style.cssText = `
 | 
			
		||||
      position: fixed;
 | 
			
		||||
      top: 20px;
 | 
			
		||||
      left: 50%;
 | 
			
		||||
      transform: translateX(-50%);
 | 
			
		||||
      z-index: 9999;
 | 
			
		||||
    `;
 | 
			
		||||
 | 
			
		||||
        if (document.body) {
 | 
			
		||||
            document.body.appendChild(container);
 | 
			
		||||
        } else {
 | 
			
		||||
            document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
                document.body.appendChild(container);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return container;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    class ToastMessage {
 | 
			
		||||
        constructor(message, type) {
 | 
			
		||||
            this.message = message;
 | 
			
		||||
            this.type = type;
 | 
			
		||||
            this.element = null;
 | 
			
		||||
            this.create();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        create() {
 | 
			
		||||
            this.element = document.createElement("div");
 | 
			
		||||
            this.element.textContent = this.message;
 | 
			
		||||
            this.element.style.cssText = `
 | 
			
		||||
        background-color: ${this.type === "success" ? "#4CAF50" : "#F44336"};
 | 
			
		||||
        color: white;
 | 
			
		||||
        padding: 12px 24px;
 | 
			
		||||
        border-radius: 4px;
 | 
			
		||||
        margin-bottom: 10px;
 | 
			
		||||
        box-shadow: 0 2px 5px rgba(0,0,0,0.2);
 | 
			
		||||
        opacity: 0;
 | 
			
		||||
        transition: opacity 0.3s ease-in-out;
 | 
			
		||||
      `;
 | 
			
		||||
            getContainer().appendChild(this.element);
 | 
			
		||||
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
                this.element.style.opacity = "1";
 | 
			
		||||
            }, 10);
 | 
			
		||||
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
                this.remove();
 | 
			
		||||
            }, 3000);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        remove() {
 | 
			
		||||
            this.element.style.opacity = "0";
 | 
			
		||||
            setTimeout(() => {
 | 
			
		||||
                const parent = this.element.parentNode;
 | 
			
		||||
                if (parent) {
 | 
			
		||||
                    parent.removeChild(this.element);
 | 
			
		||||
                }
 | 
			
		||||
            }, 300);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    function showToast(message, type) {
 | 
			
		||||
        if (document.readyState === "loading") {
 | 
			
		||||
            document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
                new ToastMessage(message, type);
 | 
			
		||||
            });
 | 
			
		||||
        } else {
 | 
			
		||||
            new ToastMessage(message, type);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
        success: function (message) {
 | 
			
		||||
            showToast(message, "success");
 | 
			
		||||
        },
 | 
			
		||||
        error: function (message) {
 | 
			
		||||
            showToast(message, "error");
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
})();
 | 
			
		||||
 | 
			
		||||
function sendVerificationCode(button, sendRequest) {
 | 
			
		||||
    let timer;
 | 
			
		||||
    const countdown = 60;
 | 
			
		||||
 | 
			
		||||
    button.addEventListener("click", () => {
 | 
			
		||||
        button.disabled = true;
 | 
			
		||||
        sendRequest()
 | 
			
		||||
            .then(() => {
 | 
			
		||||
                startCountdown();
 | 
			
		||||
                Toast.success("发送成功");
 | 
			
		||||
            })
 | 
			
		||||
            .catch((e) => {
 | 
			
		||||
                button.disabled = false;
 | 
			
		||||
                if (e instanceof Error) {
 | 
			
		||||
                    Toast.error(e.message);
 | 
			
		||||
                } else {
 | 
			
		||||
                    Toast.error("发送失败,请稍后再试");
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    function startCountdown() {
 | 
			
		||||
        let remainingTime = countdown;
 | 
			
		||||
        button.disabled = true;
 | 
			
		||||
        button.classList.add("disabled");
 | 
			
		||||
 | 
			
		||||
        timer = setInterval(() => {
 | 
			
		||||
            if (remainingTime > 0) {
 | 
			
		||||
                button.textContent = `${remainingTime}s`;
 | 
			
		||||
                remainingTime--;
 | 
			
		||||
            } else {
 | 
			
		||||
                clearInterval(timer);
 | 
			
		||||
                button.textContent = "Send";
 | 
			
		||||
                button.disabled = false;
 | 
			
		||||
                button.classList.remove("disabled");
 | 
			
		||||
            }
 | 
			
		||||
        }, 1000);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
document.addEventListener("DOMContentLoaded", () => {
 | 
			
		||||
    const passwordContainers = document.querySelectorAll(".toggle-password-display-flag");
 | 
			
		||||
 | 
			
		||||
    passwordContainers.forEach((container) => {
 | 
			
		||||
        const passwordInput = container.querySelector('input[type="password"]');
 | 
			
		||||
        const toggleButton = container.querySelector(".toggle-password-button");
 | 
			
		||||
        const displayIcon = container.querySelector(".password-display-icon");
 | 
			
		||||
        const hiddenIcon = container.querySelector(".password-hidden-icon");
 | 
			
		||||
 | 
			
		||||
        if (passwordInput && toggleButton && displayIcon && hiddenIcon) {
 | 
			
		||||
            toggleButton.addEventListener("click", () => {
 | 
			
		||||
                if (passwordInput.type === "password") {
 | 
			
		||||
                    passwordInput.type = "text";
 | 
			
		||||
                    displayIcon.style.display = "none";
 | 
			
		||||
                    hiddenIcon.style.display = "block";
 | 
			
		||||
                } else {
 | 
			
		||||
                    passwordInput.type = "password";
 | 
			
		||||
                    displayIcon.style.display = "block";
 | 
			
		||||
                    hiddenIcon.style.display = "none";
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,410 @@
 | 
			
		|||
/* Base */
 | 
			
		||||
.gateway-page {
 | 
			
		||||
    width: 100vw;
 | 
			
		||||
    height: 100vh;
 | 
			
		||||
    background-color: #f5f5f5;
 | 
			
		||||
    overflow: auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gateway-wrapper,
 | 
			
		||||
.gateway-wrapper:before,
 | 
			
		||||
.gateway-wrapper:after {
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    border-width: 0;
 | 
			
		||||
    border-style: solid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gateway-wrapper *,
 | 
			
		||||
.gateway-wrapper *:before,
 | 
			
		||||
.gateway-wrapper *:after {
 | 
			
		||||
    box-sizing: border-box;
 | 
			
		||||
    border-width: 0;
 | 
			
		||||
    border-style: solid;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gateway-wrapper {
 | 
			
		||||
    --color-primary: #4ccba0;
 | 
			
		||||
    --color-secondary: #0e1731;
 | 
			
		||||
    --color-link: #1f75cb;
 | 
			
		||||
    --color-text: #374151;
 | 
			
		||||
    --color-border: #d1d5db;
 | 
			
		||||
    --rounded-sm: 0.125em;
 | 
			
		||||
    --rounded-base: 0.25em;
 | 
			
		||||
    --rounded-lg: 0.5em;
 | 
			
		||||
    --spacing-xl: 1.25em;
 | 
			
		||||
    --spacing-lg: 1em;
 | 
			
		||||
    --spacing-md: 0.875em;
 | 
			
		||||
    --spacing-sm: 0.5em;
 | 
			
		||||
    --text-md: 0.875em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.gateway-wrapper {
 | 
			
		||||
    margin: 0 auto;
 | 
			
		||||
    max-width: 28em;
 | 
			
		||||
    padding: 5% 1em;
 | 
			
		||||
    font-family:
 | 
			
		||||
        ui-sans-serif,
 | 
			
		||||
        system-ui,
 | 
			
		||||
        -apple-system,
 | 
			
		||||
        BlinkMacSystemFont,
 | 
			
		||||
        Segoe UI,
 | 
			
		||||
        Roboto,
 | 
			
		||||
        Helvetica Neue,
 | 
			
		||||
        Arial,
 | 
			
		||||
        Noto Sans,
 | 
			
		||||
        sans-serif,
 | 
			
		||||
        "Apple Color Emoji",
 | 
			
		||||
        "Segoe UI Emoji",
 | 
			
		||||
        Segoe UI Symbol,
 | 
			
		||||
        "Noto Color Emoji";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Form */
 | 
			
		||||
.halo-form-wrapper {
 | 
			
		||||
    border-radius: var(--rounded-lg);
 | 
			
		||||
    background: #fff;
 | 
			
		||||
    padding: 1.5em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-title {
 | 
			
		||||
    all: unset;
 | 
			
		||||
    margin-bottom: 1em;
 | 
			
		||||
    display: block;
 | 
			
		||||
    font-weight: 500;
 | 
			
		||||
    font-size: 1.75em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    margin-bottom: 1.3em;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item:last-child {
 | 
			
		||||
    margin-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item-group {
 | 
			
		||||
    gap: var(--spacing-lg);
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    margin-bottom: 1.3em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item-group .form-item {
 | 
			
		||||
    margin-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input {
 | 
			
		||||
    border-radius: var(--rounded-base);
 | 
			
		||||
    border: 1px solid var(--color-border);
 | 
			
		||||
    height: 2.5em;
 | 
			
		||||
    background: #fff;
 | 
			
		||||
    padding: 0 0.75rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input:focus-within {
 | 
			
		||||
    border-color: var(--color-primary);
 | 
			
		||||
    outline: 2px solid transparent;
 | 
			
		||||
    outline-offset: "2px";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item input {
 | 
			
		||||
    appearance: none;
 | 
			
		||||
    -webkit-appearance: none;
 | 
			
		||||
    -moz-appearance: none;
 | 
			
		||||
    display: block;
 | 
			
		||||
    font-size: 1em;
 | 
			
		||||
    box-shadow: none;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    background: transparent;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item input:focus {
 | 
			
		||||
    outline: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input-stack {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    gap: 0.5em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input-stack-icon {
 | 
			
		||||
    display: inline-flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    color: var(--color-text);
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input-stack-select {
 | 
			
		||||
    all: unset;
 | 
			
		||||
    color: var(--color-text);
 | 
			
		||||
    font-size: var(--text-md);
 | 
			
		||||
    padding-right: 1.85em;
 | 
			
		||||
    display: inline-flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m12 13.171l4.95-4.95l1.414 1.415L12 16L5.636 9.636L7.05 8.222z'/%3E%3C/svg%3E");
 | 
			
		||||
    background-repeat: no-repeat;
 | 
			
		||||
    background-position: right 0.3em center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input-stack-text {
 | 
			
		||||
    color: var(--color-text);
 | 
			
		||||
    font-size: var(--text-md);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item label {
 | 
			
		||||
    color: var(--color-text);
 | 
			
		||||
    margin-bottom: 0.5em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item .form-label-group {
 | 
			
		||||
    margin-bottom: 0.5em;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    justify-content: space-between;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item .form-label-group label {
 | 
			
		||||
    margin-bottom: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item-extra-link {
 | 
			
		||||
    color: var(--color-link);
 | 
			
		||||
    font-size: var(--text-md);
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item-compact {
 | 
			
		||||
    gap: var(--spacing-sm);
 | 
			
		||||
    margin-bottom: 1.5em;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-item-compact label {
 | 
			
		||||
    color: var(--color-text);
 | 
			
		||||
    font-size: var(--text-md);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form button[type="submit"] {
 | 
			
		||||
    background: var(--color-secondary);
 | 
			
		||||
    border-radius: var(--rounded-base);
 | 
			
		||||
    height: 2.5em;
 | 
			
		||||
    color: #fff;
 | 
			
		||||
    border: none;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form button[type="submit"]:hover {
 | 
			
		||||
    opacity: 0.8;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form button[type="submit"]:active {
 | 
			
		||||
    opacity: 0.9;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form button[disabled] {
 | 
			
		||||
    cursor: not-allowed !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form input[type="checkbox"] {
 | 
			
		||||
    border: 1px solid var(--color-border);
 | 
			
		||||
    border-radius: var(--rounded-sm);
 | 
			
		||||
    appearance: none;
 | 
			
		||||
    -webkit-appearance: none;
 | 
			
		||||
    -moz-appearance: none;
 | 
			
		||||
    padding: 0;
 | 
			
		||||
    print-color-adjust: exact;
 | 
			
		||||
    display: inline-block;
 | 
			
		||||
    vertical-align: middle;
 | 
			
		||||
    background-origin: border-box;
 | 
			
		||||
    user-select: none;
 | 
			
		||||
    flex-shrink: 0;
 | 
			
		||||
    height: 1em;
 | 
			
		||||
    width: 1em;
 | 
			
		||||
    color: #2563eb;
 | 
			
		||||
    background-color: #fff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form input[type="checkbox"]:focus {
 | 
			
		||||
    outline: 2px solid transparent;
 | 
			
		||||
    outline-offset: 2px;
 | 
			
		||||
    box-shadow:
 | 
			
		||||
        rgb(255, 255, 255) 0px 0px 0px 2px,
 | 
			
		||||
        rgb(37, 99, 235) 0px 0px 0px 4px,
 | 
			
		||||
        rgba(0, 0, 0, 0) 0px 0px 0px 0px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form input[type="checkbox"]:checked {
 | 
			
		||||
    border-color: transparent;
 | 
			
		||||
    background-color: currentColor;
 | 
			
		||||
    background-size: 100% 100%;
 | 
			
		||||
    background-position: center;
 | 
			
		||||
    background-repeat: no-repeat;
 | 
			
		||||
    background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input-group {
 | 
			
		||||
    gap: var(--spacing-sm);
 | 
			
		||||
    display: grid;
 | 
			
		||||
    grid-template-columns: repeat(3, minmax(0, 1fr));
 | 
			
		||||
    align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input {
 | 
			
		||||
    grid-column: span 2 / span 2;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input-group button {
 | 
			
		||||
    border-radius: var(--rounded-base);
 | 
			
		||||
    border: 1px solid var(--color-border);
 | 
			
		||||
    color: var(--color-text);
 | 
			
		||||
    font-size: var(--text-md);
 | 
			
		||||
    grid-column: span 1 / span 1;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    cursor: pointer;
 | 
			
		||||
    background: #fff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input-group button:hover {
 | 
			
		||||
    color: #333;
 | 
			
		||||
    background: #f3f4f6;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.halo-form .form-input-group button:active {
 | 
			
		||||
    background: #f9fafb;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-provider-items {
 | 
			
		||||
    all: unset;
 | 
			
		||||
    gap: var(--spacing-md);
 | 
			
		||||
    margin: 0;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-wrap: wrap;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-provider-items li {
 | 
			
		||||
    all: unset;
 | 
			
		||||
    border-radius: var(--rounded-lg);
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    border: 1px solid #e5e7eb;
 | 
			
		||||
    transition-property: all;
 | 
			
		||||
    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
 | 
			
		||||
    transition-duration: 0.15s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-provider-items li a {
 | 
			
		||||
    gap: var(--spacing-sm);
 | 
			
		||||
    padding: 0.7em 1em;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    color: #1f2937;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    font-size: 0.8em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-provider-items li img {
 | 
			
		||||
    width: 1.5em;
 | 
			
		||||
    height: 1.5em;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-provider-items li:hover {
 | 
			
		||||
    border-color: var(--color-primary);
 | 
			
		||||
    background: #f3f4f6;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-provider-items li:hover a {
 | 
			
		||||
    color: #111827;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.auth-provider-items li:focus-within {
 | 
			
		||||
    border-color: var(--color-primary);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.divider-wrapper {
 | 
			
		||||
    color: var(--color-text);
 | 
			
		||||
    font-size: var(--text-md);
 | 
			
		||||
    gap: var(--spacing-lg);
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    margin: 1.5em 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.divider-wrapper hr {
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    border: 0;
 | 
			
		||||
    border-top: 1px solid #f3f4f6;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert {
 | 
			
		||||
    border: 1px solid #e5e7eb;
 | 
			
		||||
    border-radius: var(--rounded-base);
 | 
			
		||||
    margin-bottom: var(--spacing-xl);
 | 
			
		||||
    padding: var(--spacing-md) var(--spacing-xl);
 | 
			
		||||
    font-size: var(--text-md);
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    position: relative;
 | 
			
		||||
    color: var(--color-text);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert::before {
 | 
			
		||||
    content: "";
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    height: 100%;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    background: #d1d5db;
 | 
			
		||||
    width: 0.25em;
 | 
			
		||||
    top: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-warning {
 | 
			
		||||
    border-color: #fde047;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-warning::before {
 | 
			
		||||
    background: #ea580c;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-error {
 | 
			
		||||
    border-color: #fca5a5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-error::before {
 | 
			
		||||
    background: #dc2626;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-success {
 | 
			
		||||
    border-color: #86efac;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-success::before {
 | 
			
		||||
    background: #16a34a;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-info {
 | 
			
		||||
    border-color: #7dd3fc;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.alert-info::before {
 | 
			
		||||
    background: #0284c7;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (forced-colors: active) {
 | 
			
		||||
    .halo-form input[type="checkbox"]:checked {
 | 
			
		||||
        -webkit-appearance: auto;
 | 
			
		||||
        -moz-appearance: auto;
 | 
			
		||||
        appearance: auto;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media only screen and (max-width: 768px) {
 | 
			
		||||
    .halo-form .form-item-group {
 | 
			
		||||
        flex-direction: column;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,43 @@
 | 
			
		|||
<!doctype html>
 | 
			
		||||
<html
 | 
			
		||||
    xmlns:th="https://www.thymeleaf.org"
 | 
			
		||||
    th:replace="~{gateway_modules/layout :: layout(title = |#{title} - ${site.title}|, head = null, body = ~{::body})}"
 | 
			
		||||
>
 | 
			
		||||
    <th:block th:fragment="body">
 | 
			
		||||
        <div class="gateway-wrapper">
 | 
			
		||||
            <div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
 | 
			
		||||
            <div class="halo-form-wrapper">
 | 
			
		||||
                <h1 class="form-title" th:text="#{title}"></h1>
 | 
			
		||||
                <form
 | 
			
		||||
                    class="halo-form"
 | 
			
		||||
                    th:action="@{/challenges/two-factor/totp}"
 | 
			
		||||
                    name="two-factor-form"
 | 
			
		||||
                    id="two-factor-form"
 | 
			
		||||
                    method="post"
 | 
			
		||||
                >
 | 
			
		||||
                    <div class="alert alert-error" role="alert" th:if="${param.error.size() > 0}">
 | 
			
		||||
                        <strong th:text="#{messages.invalidError}"></strong>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <label for="code" th:text="#{form.code.label}"></label>
 | 
			
		||||
                        <div class="form-input">
 | 
			
		||||
                            <input
 | 
			
		||||
                                type="text"
 | 
			
		||||
                                inputmode="numeric"
 | 
			
		||||
                                id="code"
 | 
			
		||||
                                name="code"
 | 
			
		||||
                                autocomplete="one-time-code"
 | 
			
		||||
                                pattern="\d{6}"
 | 
			
		||||
                                autofocus
 | 
			
		||||
                                required
 | 
			
		||||
                            />
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <button type="submit" th:text="#{form.submit}"></button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </form>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </th:block>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
title=两步验证
 | 
			
		||||
messages.invalidError=错误的验证码
 | 
			
		||||
form.code.label=验证码
 | 
			
		||||
form.submit=验证
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
title=Two-Factor Authentication
 | 
			
		||||
messages.invalidError=Invalid TOTP code
 | 
			
		||||
form.code.label=TOTP Code
 | 
			
		||||
form.submit=Verify
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,110 @@
 | 
			
		|||
<th:block th:fragment="basicStaticResources">
 | 
			
		||||
    <script th:inline="javascript">
 | 
			
		||||
        const resources = {
 | 
			
		||||
            title: `[(#{title})]`,
 | 
			
		||||
        };
 | 
			
		||||
    </script>
 | 
			
		||||
 | 
			
		||||
    <link rel="stylesheet" href="/webjars/normalize.css/8.0.1/normalize.css" />
 | 
			
		||||
    <link rel="stylesheet" th:href="|/styles/main.css?v=${site.version}|" />
 | 
			
		||||
 | 
			
		||||
    <script src="/js/main.js"></script>
 | 
			
		||||
</th:block>
 | 
			
		||||
 | 
			
		||||
<div th:remove="tag" th:fragment="languageSwitcher">
 | 
			
		||||
    <style>
 | 
			
		||||
        .language-switcher {
 | 
			
		||||
            display: flex;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
            margin: 2em 0;
 | 
			
		||||
            gap: 0.625rem;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .language-switcher label {
 | 
			
		||||
            color: var(--color-text);
 | 
			
		||||
            display: inline-flex;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .language-switcher select {
 | 
			
		||||
            all: unset;
 | 
			
		||||
            border: 1px solid var(--color-border);
 | 
			
		||||
            font-size: var(--text-md);
 | 
			
		||||
            height: 2em;
 | 
			
		||||
            border-radius: var(--rounded-lg);
 | 
			
		||||
            outline: none;
 | 
			
		||||
            padding: 0 2em 0 0.5em;
 | 
			
		||||
            display: inline-flex;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            color: var(--color-text);
 | 
			
		||||
            background-color: var(--color-text);
 | 
			
		||||
            background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1em' height='1em' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m12 13.171l4.95-4.95l1.414 1.415L12 16L5.636 9.636L7.05 8.222z'/%3E%3C/svg%3E");
 | 
			
		||||
            background-repeat: no-repeat;
 | 
			
		||||
            background-position: right 0.45rem center;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .language-switcher select:focus {
 | 
			
		||||
            border-color: var(--color-primary);
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
    <div class="language-switcher">
 | 
			
		||||
        <label>
 | 
			
		||||
            <svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
 | 
			
		||||
                <path
 | 
			
		||||
                    fill="currentColor"
 | 
			
		||||
                    d="m12.87 15.07l-2.54-2.51l.03-.03A17.5 17.5 0 0 0 14.07 6H17V4h-7V2H8v2H1v2h11.17C11.5 7.92 10.44 9.75 9 11.35C8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5l3.11 3.11zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2zm-2.62 7l1.62-4.33L19.12 17z"
 | 
			
		||||
                ></path>
 | 
			
		||||
            </svg>
 | 
			
		||||
        </label>
 | 
			
		||||
        <select id="language-select" onchange="changeLanguage()">
 | 
			
		||||
            <option value="en" th:selected="${#locale.toLanguageTag} == 'en'">English</option>
 | 
			
		||||
            <option value="es" th:selected="${#locale.toLanguageTag} == 'es'">Español</option>
 | 
			
		||||
            <option value="zh-CN" th:selected="${#locale.toLanguageTag} == 'zh-CN'">简体中文</option>
 | 
			
		||||
            <option value="zh-TW" th:selected="${#locale.toLanguageTag} == 'zh-TW'">繁体中文</option>
 | 
			
		||||
        </select>
 | 
			
		||||
        <script type="text/javascript">
 | 
			
		||||
            function changeLanguage() {
 | 
			
		||||
                const selectedLanguage = document.getElementById("language-select").value;
 | 
			
		||||
                const currentURL = new URL(window.location.href);
 | 
			
		||||
                currentURL.searchParams.set("language", selectedLanguage);
 | 
			
		||||
                window.location.href = currentURL.toString();
 | 
			
		||||
            }
 | 
			
		||||
        </script>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div th:remove="tag" th:fragment="haloLogo">
 | 
			
		||||
    <style>
 | 
			
		||||
        .halo-logo {
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
            margin-bottom: 2em;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .halo-logo img {
 | 
			
		||||
            width: 6em;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
    <div class="halo-logo">
 | 
			
		||||
        <img src="/images/wordmark.svg" />
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div th:remove="tag" th:fragment="socialAuthProviders">
 | 
			
		||||
    <th:block th:unless="${#lists.isEmpty(socialAuthProviders)}">
 | 
			
		||||
        <div class="divider-wrapper">
 | 
			
		||||
            <hr />
 | 
			
		||||
            <th:block th:text="#{socialLogin.label}"></th:block>
 | 
			
		||||
            <hr />
 | 
			
		||||
        </div>
 | 
			
		||||
        <ul class="auth-provider-items">
 | 
			
		||||
            <li th:each="provider : ${socialAuthProviders}">
 | 
			
		||||
                <a th:href="${provider.spec.authenticationUrl}">
 | 
			
		||||
                    <img th:src="${provider.spec.logo}" />
 | 
			
		||||
                    <span th:text="${provider.spec.displayName}"></span>
 | 
			
		||||
                </a>
 | 
			
		||||
            </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
    </th:block>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
socialLogin.label=社交登录
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
socialLogin.label=Social Login
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,31 @@
 | 
			
		|||
<div th:remove="tag" th:fragment="password(id,name,required,minlength,enableToggle)">
 | 
			
		||||
    <div class="form-input" th:classappend="${enableToggle ? 'form-input-stack toggle-password-display-flag' : ''}">
 | 
			
		||||
        <input
 | 
			
		||||
            th:id="${id}"
 | 
			
		||||
            th:name="${name}"
 | 
			
		||||
            type="password"
 | 
			
		||||
            autocomplete="off"
 | 
			
		||||
            spellcheck="false"
 | 
			
		||||
            autocorrect="off"
 | 
			
		||||
            autocapitalize="off"
 | 
			
		||||
            th:required="${required}"
 | 
			
		||||
            th:minlength="${minlength}"
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <div th:if="${enableToggle}" class="form-input-stack-icon toggle-password-button">
 | 
			
		||||
            <svg class="password-hidden-icon" style="display: none" viewBox="0 0 24 24" width="1em" height="1em">
 | 
			
		||||
                <path
 | 
			
		||||
                    fill="currentColor"
 | 
			
		||||
                    d="M12 3c5.392 0 9.878 3.88 10.819 9c-.94 5.12-5.427 9-10.819 9c-5.392 0-9.878-3.88-10.818-9C2.122 6.88 6.608 3 12 3Zm0 16a9.005 9.005 0 0 0 8.778-7a9.005 9.005 0 0 0-17.555 0A9.005 9.005 0 0 0 12 19Zm0-2.5a4.5 4.5 0 1 1 0-9a4.5 4.5 0 0 1 0 9Zm0-2a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5Z"
 | 
			
		||||
                ></path>
 | 
			
		||||
            </svg>
 | 
			
		||||
 | 
			
		||||
            <svg class="password-display-icon" viewBox="0 0 24 24" width="1em" height="1em">
 | 
			
		||||
                <path
 | 
			
		||||
                    fill="currentColor"
 | 
			
		||||
                    d="M17.883 19.297A10.949 10.949 0 0 1 12 21c-5.392 0-9.878-3.88-10.818-9A10.982 10.982 0 0 1 4.52 5.935L1.394 2.808l1.414-1.414l19.799 19.798l-1.414 1.415l-3.31-3.31ZM5.936 7.35A8.965 8.965 0 0 0 3.223 12a9.005 9.005 0 0 0 13.201 5.838l-2.028-2.028A4.5 4.5 0 0 1 8.19 9.604L5.936 7.35Zm6.978 6.978l-3.242-3.241a2.5 2.5 0 0 0 3.241 3.241Zm7.893 2.265l-1.431-1.431A8.935 8.935 0 0 0 20.778 12A9.005 9.005 0 0 0 9.552 5.338L7.974 3.76C9.221 3.27 10.58 3 12 3c5.392 0 9.878 3.88 10.819 9a10.947 10.947 0 0 1-2.012 4.593Zm-9.084-9.084a4.5 4.5 0 0 1 4.769 4.769l-4.77-4.77Z"
 | 
			
		||||
                ></path>
 | 
			
		||||
            </svg>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
<!doctype html>
 | 
			
		||||
<html th:lang="${#locale.toLanguageTag}" th:fragment="layout (title,head,body)">
 | 
			
		||||
    <head>
 | 
			
		||||
        <meta charset="UTF-8" />
 | 
			
		||||
        <meta content="IE=edge" http-equiv="X-UA-Compatible" />
 | 
			
		||||
        <meta content="webkit" name="renderer" />
 | 
			
		||||
        <meta
 | 
			
		||||
            content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
 | 
			
		||||
            name="viewport"
 | 
			
		||||
        />
 | 
			
		||||
        <meta content="noindex,nofollow" name="robots" />
 | 
			
		||||
        <title th:text="${title}"></title>
 | 
			
		||||
 | 
			
		||||
        <th:block th:replace="~{gateway_modules/common_fragments::basicStaticResources}"></th:block>
 | 
			
		||||
 | 
			
		||||
        <th:block th:if="${head != null}">
 | 
			
		||||
            <th:block th:replace="${head}" />
 | 
			
		||||
        </th:block>
 | 
			
		||||
    </head>
 | 
			
		||||
    <body class="gateway-page">
 | 
			
		||||
        <th:block th:replace="${body}" />
 | 
			
		||||
    </body>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,95 @@
 | 
			
		|||
<!-- Those fragments are only for login template-->
 | 
			
		||||
<form
 | 
			
		||||
        th:fragment="form"
 | 
			
		||||
        class="halo-form"
 | 
			
		||||
        name="login-form"
 | 
			
		||||
        id="login-form"
 | 
			
		||||
        th:action="${authProvider.spec.authenticationUrl}"
 | 
			
		||||
        th:method="${authProvider.spec.method}"
 | 
			
		||||
>
 | 
			
		||||
    <div class="alert alert-error" role="alert" th:if="${param.error.size() > 0}" th:with="error = ${param.error[0]}">
 | 
			
		||||
        <strong th:if="${error == 'invalid-credential'}">
 | 
			
		||||
            <span th:text="#{error.invalid-credential}"></span>
 | 
			
		||||
        </strong>
 | 
			
		||||
        <strong th:if="${error == 'rate-limit-exceeded'}">
 | 
			
		||||
            <span th:text="#{error.rate-limit-exceeded}"></span>
 | 
			
		||||
        </strong>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="alert" role="alert" th:if="${param.logout.size() > 0}">
 | 
			
		||||
        <strong th:text="#{messages.logoutSuccess}"></strong>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="alert" role="alert" th:if="${param.signup.size() > 0}">
 | 
			
		||||
        <strong th:text="#{messages.signupSuccess}"> </strong>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div th:replace="~{__${fragmentTemplateName}__::form}"></div>
 | 
			
		||||
 | 
			
		||||
    <div th:if="${authProvider.spec.rememberMeSupport}" class="form-item-compact">
 | 
			
		||||
        <input type="checkbox" id="remember-me" name="remember-me" value="true"/>
 | 
			
		||||
        <label for="remember-me" th:text="#{form.rememberMe.label}"></label>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div class="form-item">
 | 
			
		||||
        <button type="submit" th:text="#{form.submit}"></button>
 | 
			
		||||
    </div>
 | 
			
		||||
</form>
 | 
			
		||||
 | 
			
		||||
<div th:remove="tag" th:fragment="formAuthProviders">
 | 
			
		||||
    <th:block th:unless="${#lists.isEmpty(formAuthProviders)}">
 | 
			
		||||
        <div class="divider-wrapper">
 | 
			
		||||
            <hr/>
 | 
			
		||||
            <th:block th:text="#{otherLogin.label}"></th:block>
 | 
			
		||||
            <hr/>
 | 
			
		||||
        </div>
 | 
			
		||||
        <ul class="auth-provider-items">
 | 
			
		||||
            <li th:each="provider : ${formAuthProviders}">
 | 
			
		||||
                <a th:href="'/login?method=' + ${provider.metadata.name}">
 | 
			
		||||
                    <img th:src="${provider.spec.logo}"/>
 | 
			
		||||
                    <span th:text="${provider.spec.displayName}"></span>
 | 
			
		||||
                </a>
 | 
			
		||||
            </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
    </th:block>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div th:remove="tag" th:fragment="miscellaneous">
 | 
			
		||||
    <style>
 | 
			
		||||
        .signup-notice,
 | 
			
		||||
        .returntosite-notice {
 | 
			
		||||
            font-size: var(--text-md);
 | 
			
		||||
            color: var(--color-text);
 | 
			
		||||
            text-align: center;
 | 
			
		||||
            margin: 1em 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .signup-notice a {
 | 
			
		||||
            color: var(--color-link);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .returntosite-notice a {
 | 
			
		||||
            color: var(--color-text);
 | 
			
		||||
            display: inline-flex;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            gap: 0.3em;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .returntosite-notice a,
 | 
			
		||||
        .signup-notice a {
 | 
			
		||||
            text-decoration: none;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
    <div th:if="${globalInfo.allowRegistration}" class="signup-notice">
 | 
			
		||||
        <div>
 | 
			
		||||
            <th:block th:text="#{signup.description}"></th:block>
 | 
			
		||||
            <a th:href="@{/signup}" th:text="#{signup.link}"></a>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="returntosite-notice">
 | 
			
		||||
        <a th:href="@{/}">
 | 
			
		||||
            <svg viewBox="0 0 24 24" width="1.2em" height="1.2em">
 | 
			
		||||
                <path fill="currentColor" d="M21 11H6.83l3.58-3.59L9 6l-6 6l6 6l1.41-1.42L6.83 13H21z"></path>
 | 
			
		||||
            </svg>
 | 
			
		||||
            <span th:text="#{returnToSite}"></span>
 | 
			
		||||
        </a>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
messages.loginError=无效的凭证。
 | 
			
		||||
messages.logoutSuccess=登出成功。
 | 
			
		||||
messages.signupSuccess=恭喜!注册成功,请立即登录。
 | 
			
		||||
 | 
			
		||||
error.invalid-credential=无效的凭证。
 | 
			
		||||
error.rate-limit-exceeded=请求过于频繁,请稍后再试。
 | 
			
		||||
 | 
			
		||||
form.rememberMe.label=保持登录会话
 | 
			
		||||
form.submit=登录
 | 
			
		||||
otherLogin.label=其他登录方式
 | 
			
		||||
signup.description=没有账号?
 | 
			
		||||
signup.link=立即注册
 | 
			
		||||
returnToSite=返回网站
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
messages.loginError=Invalid credentials.
 | 
			
		||||
messages.logoutSuccess=Logout successfully.
 | 
			
		||||
messages.signupSuccess=Congratulations! Sign up successfully, please sign in now.
 | 
			
		||||
 | 
			
		||||
error.invalid-credential=Invalid credentials.
 | 
			
		||||
error.rate-limit-exceeded=Too many requests, please try again later.
 | 
			
		||||
 | 
			
		||||
form.rememberMe.label=Remember me
 | 
			
		||||
form.submit=Login
 | 
			
		||||
otherLogin.label=Other Login
 | 
			
		||||
signup.description=Don't have an account?
 | 
			
		||||
signup.link=Sign up
 | 
			
		||||
returnToSite=Return to site
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
<!doctype html>
 | 
			
		||||
<html
 | 
			
		||||
    xmlns:th="https://www.thymeleaf.org"
 | 
			
		||||
    th:replace="~{gateway_modules/layout :: layout(title = |#{title} - ${site.title}|, head = null, body = ~{::body})}"
 | 
			
		||||
>
 | 
			
		||||
    <th:block th:fragment="body">
 | 
			
		||||
        <div class="gateway-wrapper">
 | 
			
		||||
            <div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
 | 
			
		||||
 | 
			
		||||
            <div class="halo-form-wrapper">
 | 
			
		||||
                <form th:replace="~{gateway_modules/login_fragments::form}"></form>
 | 
			
		||||
                <div th:replace="~{gateway_modules/login_fragments::formAuthProviders}"></div>
 | 
			
		||||
                <div th:replace="~{gateway_modules/common_fragments::socialAuthProviders}"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <div th:replace="~{gateway_modules/login_fragments::miscellaneous}"></div>
 | 
			
		||||
            <div th:replace="~{gateway_modules/common_fragments::languageSwitcher}"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </th:block>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
title=登录
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,37 @@
 | 
			
		|||
<div th:remove="tag" th:fragment="form">
 | 
			
		||||
    <div class="form-item">
 | 
			
		||||
        <label for="email"> Email </label>
 | 
			
		||||
 | 
			
		||||
        <div class="form-input">
 | 
			
		||||
            <input
 | 
			
		||||
                type="email"
 | 
			
		||||
                id="email"
 | 
			
		||||
                name="email"
 | 
			
		||||
                autocomplete="off"
 | 
			
		||||
                spellcheck="false"
 | 
			
		||||
                autocorrect="off"
 | 
			
		||||
                autocapitalize="off"
 | 
			
		||||
                required
 | 
			
		||||
                autofocus
 | 
			
		||||
            />
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="form-item">
 | 
			
		||||
        <label for="code"> Code </label>
 | 
			
		||||
        <div class="form-input-group">
 | 
			
		||||
            <div class="form-input">
 | 
			
		||||
                <input
 | 
			
		||||
                    type="text"
 | 
			
		||||
                    id="code"
 | 
			
		||||
                    name="code"
 | 
			
		||||
                    autocomplete="off"
 | 
			
		||||
                    spellcheck="false"
 | 
			
		||||
                    autocorrect="off"
 | 
			
		||||
                    autocapitalize="off"
 | 
			
		||||
                    required
 | 
			
		||||
                />
 | 
			
		||||
            </div>
 | 
			
		||||
            <button type="button">Send</button>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
title=Login
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,56 @@
 | 
			
		|||
<div th:remove="tag" th:fragment="form">
 | 
			
		||||
    <script src="/webjars/jsencrypt/3.3.2/bin/jsencrypt.min.js" defer></script>
 | 
			
		||||
    <script th:inline="javascript" type="text/javascript">
 | 
			
		||||
        const publicKey = /*[[${publicKey}]]*/ "";
 | 
			
		||||
 | 
			
		||||
        // Encrypt function
 | 
			
		||||
        function encryptPassword(password) {
 | 
			
		||||
            const encrypt = new JSEncrypt();
 | 
			
		||||
            encrypt.setPublicKey(publicKey);
 | 
			
		||||
            return encrypt.encrypt(password);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
            const loginForm = document.getElementById("login-form");
 | 
			
		||||
            loginForm.addEventListener("submit", function (event) {
 | 
			
		||||
                const passwordInput = document.getElementById("password");
 | 
			
		||||
                const password = passwordInput.value;
 | 
			
		||||
                passwordInput.value = encryptPassword(password);
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
    </script>
 | 
			
		||||
 | 
			
		||||
    <div class="form-item">
 | 
			
		||||
        <label for="username" th:text="#{form.username.label}"> </label>
 | 
			
		||||
 | 
			
		||||
        <div class="form-input">
 | 
			
		||||
            <input
 | 
			
		||||
                id="username"
 | 
			
		||||
                name="username"
 | 
			
		||||
                type="text"
 | 
			
		||||
                autocomplete="off"
 | 
			
		||||
                spellcheck="false"
 | 
			
		||||
                autocorrect="off"
 | 
			
		||||
                autocapitalize="off"
 | 
			
		||||
                required
 | 
			
		||||
                autofocus
 | 
			
		||||
            />
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="form-item">
 | 
			
		||||
        <div class="form-label-group">
 | 
			
		||||
            <label for="password" th:text="#{form.password.label}"> </label>
 | 
			
		||||
            <a
 | 
			
		||||
                class="form-item-extra-link"
 | 
			
		||||
                tabindex="-1"
 | 
			
		||||
                th:href="@{/password-reset}"
 | 
			
		||||
                th:text="#{form.password.forgot}"
 | 
			
		||||
            >
 | 
			
		||||
            </a>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <th:block
 | 
			
		||||
            th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = null, enableToggle = true)}"
 | 
			
		||||
        ></th:block>
 | 
			
		||||
    </div>
 | 
			
		||||
</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
form.username.label=用户名
 | 
			
		||||
form.password.label=密码
 | 
			
		||||
form.password.forgot=忘记密码?
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
form.username.label=Username
 | 
			
		||||
form.password.label=Password
 | 
			
		||||
form.password.forgot=Forgot your password?
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
<!doctype html>
 | 
			
		||||
<html
 | 
			
		||||
    xmlns:th="https://www.thymeleaf.org"
 | 
			
		||||
    th:replace="~{gateway_modules/layout:: layout(title = |#{title} - ${site.title}|, head = null, body = ~{::body})}"
 | 
			
		||||
>
 | 
			
		||||
    <th:block th:fragment="body">
 | 
			
		||||
        <div class="gateway-wrapper">
 | 
			
		||||
            <div class="halo-form-wrapper">
 | 
			
		||||
                <h1 class="form-title" th:text="#{form.title}"></h1>
 | 
			
		||||
                <form class="halo-form" id="logout-form" name="logout-form" th:action="@{/logout}" method="post">
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <button type="submit" th:text="#{form.submit}"></button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </form>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </th:block>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
title=退出登录
 | 
			
		||||
form.title=确定要退出登录吗?
 | 
			
		||||
form.submit=退出登录
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
title=Logout
 | 
			
		||||
form.title=Are you sure want to log out?
 | 
			
		||||
form.submit=Logout
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
<!doctype html>
 | 
			
		||||
<html
 | 
			
		||||
    xmlns:th="https://www.thymeleaf.org"
 | 
			
		||||
    th:replace="~{gateway_modules/layout:: layout(title = |#{title(${username})} - ${site.title}|, head = null, body = ~{::body})}"
 | 
			
		||||
>
 | 
			
		||||
    <th:block th:fragment="body">
 | 
			
		||||
        <div class="gateway-wrapper">
 | 
			
		||||
            <div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
 | 
			
		||||
            <div class="halo-form-wrapper">
 | 
			
		||||
                <h1 class="form-title" th:text="#{title(${username})}"></h1>
 | 
			
		||||
                <p style="color: red" role="alert" th:if="${error}" th:text="${error}"></p>
 | 
			
		||||
                <form
 | 
			
		||||
                    class="halo-form"
 | 
			
		||||
                    th:action="@{/password-reset/{resetToken}(resetToken=${resetToken})}"
 | 
			
		||||
                    method="post"
 | 
			
		||||
                >
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <label for="password" th:text="#{form.password.label}">Password</label>
 | 
			
		||||
                        <th:block
 | 
			
		||||
                            th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = 6, enableToggle = true)}"
 | 
			
		||||
                        ></th:block>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <label for="confirmPassword" th:text="#{form.confirmPassword.label}">Confirm Password</label>
 | 
			
		||||
                        <th:block
 | 
			
		||||
                            th:replace="~{gateway_modules/input_fragments :: password(id = 'confirmPassword', name = 'confirmPassword', required = 'true', minlength = 6, enableToggle = true)}"
 | 
			
		||||
                        ></th:block>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <p th:text="#{form.password.tips}"></p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <button type="submit" th:text="#{form.submit}">Change password</button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </form>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </th:block>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
title=为 {0} 修改密码
 | 
			
		||||
form.password.label=密码
 | 
			
		||||
form.confirmPassword.label=确认密码
 | 
			
		||||
form.password.tips=密码必须至少包含 8 个字符,并且至少包含一个大写字母、一个小写字母、一个数字和一个特殊字符。
 | 
			
		||||
form.submit=修改密码
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
title=Change password for @{0}
 | 
			
		||||
form.password.label=Password
 | 
			
		||||
form.confirmPassword.label=Confirm Password
 | 
			
		||||
form.password.tips=Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.
 | 
			
		||||
form.submit=Change password
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
<!doctype html>
 | 
			
		||||
<html
 | 
			
		||||
    xmlns:th="https://www.thymeleaf.org"
 | 
			
		||||
    th:replace="~{gateway_modules/layout:: layout(title = |#{title} - ${site.title}|, head = null, body = ~{::body})}"
 | 
			
		||||
>
 | 
			
		||||
    <th:block th:fragment="body">
 | 
			
		||||
        <div class="gateway-wrapper">
 | 
			
		||||
            <div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
 | 
			
		||||
            <div class="halo-form-wrapper">
 | 
			
		||||
                <h1 class="form-title" th:text="${sent} ? #{sent.title} : #{title}"></h1>
 | 
			
		||||
                <div class="alert alert-error" th:if="${error}">
 | 
			
		||||
                    <strong th:text="${error}"></strong>
 | 
			
		||||
                </div>
 | 
			
		||||
                <form th:if="${sent}" method="get" action="/login" class="halo-form">
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <div class="alert" th:text="#{sent.form.message}"></div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <button type="submit" th:text="#{sent.form.submit}"></button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </form>
 | 
			
		||||
                <form th:unless="${sent}" class="halo-form" th:action="@{/password-reset}" method="post">
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <label for="email" th:text="#{form.email.label}"></label>
 | 
			
		||||
                        <div class="form-input">
 | 
			
		||||
                            <input type="email" id="email" name="email" autofocus required />
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <button type="submit" th:text="#{form.submit}"></button>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </form>
 | 
			
		||||
            </div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </th:block>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
title=重置密码
 | 
			
		||||
form.email.label=电子邮箱
 | 
			
		||||
form.submit=提交
 | 
			
		||||
sent.form.submit=返回到登录页面
 | 
			
		||||
sent.form.message=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。
 | 
			
		||||
sent.title=已发送重置密码的邮件
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
title=Reset password
 | 
			
		||||
form.email.label=Email
 | 
			
		||||
form.submit=Submit
 | 
			
		||||
sent.form.submit=Return to login
 | 
			
		||||
sent.form.message=Check your email for a link to reset your password. If it doesn’t appear within a few minutes, check your spam folder.
 | 
			
		||||
sent.title=Password reset email has been sent
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,202 @@
 | 
			
		|||
<!doctype html>
 | 
			
		||||
<html
 | 
			
		||||
        xmlns:th="https://www.thymeleaf.org"
 | 
			
		||||
        th:replace="~{gateway_modules/layout :: layout(title = |#{title} - ${site.title}|, head = ~{::head}, body = ~{::body})}"
 | 
			
		||||
>
 | 
			
		||||
<th:block th:fragment="head">
 | 
			
		||||
    <style>
 | 
			
		||||
        .signup-page-wrapper {
 | 
			
		||||
            max-width: 35em;
 | 
			
		||||
        }
 | 
			
		||||
    </style>
 | 
			
		||||
</th:block>
 | 
			
		||||
<th:block th:fragment="body">
 | 
			
		||||
    <div class="gateway-wrapper signup-page-wrapper">
 | 
			
		||||
        <div th:replace="~{gateway_modules/common_fragments::haloLogo}"></div>
 | 
			
		||||
        <div class="halo-form-wrapper">
 | 
			
		||||
            <h1 class="form-title" th:text="#{title}"></h1>
 | 
			
		||||
            <p class="alert alert=erro" role="alert" th:if="${error == 'invalid-email-code'}">
 | 
			
		||||
                <span th:text="#{error.invalid-email-code}">Invalid email code</span>
 | 
			
		||||
            </p>
 | 
			
		||||
            <p class="alert alert=erro" role="alert" th:if="${error == 'rate-limit-exceeded'}">
 | 
			
		||||
                <span th:text="#{error.rate-limit-exceeded}">Rate limit exceeded</span>
 | 
			
		||||
            </p>
 | 
			
		||||
            <p class="alert alert=erro" role="alert" th:if="${error == 'duplicate-name'}">
 | 
			
		||||
                <span th:text="#{error.duplicate-name}">Duplicate name</span>
 | 
			
		||||
            </p>
 | 
			
		||||
            <form class="halo-form" name="signup-form" id="signup-form" th:action="@{/signup}" th:object="${form}"
 | 
			
		||||
                  method="post">
 | 
			
		||||
                <div class="form-item-group">
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <label for="username" th:text="#{form.username.label}"></label>
 | 
			
		||||
                        <div class="form-input">
 | 
			
		||||
                            <input
 | 
			
		||||
                                    type="text"
 | 
			
		||||
                                    id="username"
 | 
			
		||||
                                    name="username"
 | 
			
		||||
                                    autocomplete="off"
 | 
			
		||||
                                    spellcheck="false"
 | 
			
		||||
                                    autocorrect="off"
 | 
			
		||||
                                    autocapitalize="off"
 | 
			
		||||
                                    autofocus
 | 
			
		||||
                                    required
 | 
			
		||||
                                    th:field="*{username}"
 | 
			
		||||
                            />
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <p class="alert alert-error"
 | 
			
		||||
                           th:if="${#fields.hasErrors('username')}"
 | 
			
		||||
                           th:errors="*{username}">
 | 
			
		||||
                        </p>
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <label for="displayName" th:text="#{form.displayName.label}"></label>
 | 
			
		||||
                        <div class="form-input">
 | 
			
		||||
                            <input
 | 
			
		||||
                                    type="text"
 | 
			
		||||
                                    id="displayName"
 | 
			
		||||
                                    name="displayName"
 | 
			
		||||
                                    autocomplete="off"
 | 
			
		||||
                                    spellcheck="false"
 | 
			
		||||
                                    autocorrect="off"
 | 
			
		||||
                                    autocapitalize="off"
 | 
			
		||||
                                    required
 | 
			
		||||
                                    th:field="*{displayName}"
 | 
			
		||||
                            />
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <p class="alert alert-error"
 | 
			
		||||
                           th:if="${#fields.hasErrors('displayName')}"
 | 
			
		||||
                           th:errors="*{displayName}">
 | 
			
		||||
                        </p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                <div class="form-item-group">
 | 
			
		||||
                    <div class="form-item">
 | 
			
		||||
                        <label for="email" th:text="#{form.email.label}"></label>
 | 
			
		||||
                        <div class="form-input">
 | 
			
		||||
                            <input
 | 
			
		||||
                                    type="email"
 | 
			
		||||
                                    id="email"
 | 
			
		||||
                                    name="email"
 | 
			
		||||
                                    autocomplete="off"
 | 
			
		||||
                                    spellcheck="false"
 | 
			
		||||
                                    autocorrect="off"
 | 
			
		||||
                                    autocapitalize="off"
 | 
			
		||||
                                    required
 | 
			
		||||
                                    th:field="*{email}"
 | 
			
		||||
                            />
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <p class="alert alert-error"
 | 
			
		||||
                           th:if="${#fields.hasErrors('email')}"
 | 
			
		||||
                           th:errors="*{email}">
 | 
			
		||||
                        </p>
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    <div class="form-item" th:if="${globalInfo.mustVerifyEmailOnRegistration}">
 | 
			
		||||
                        <label for="emailCode" th:text="#{form.emailCode.label}"></label>
 | 
			
		||||
                        <div class="form-input-group">
 | 
			
		||||
                            <div class="form-input">
 | 
			
		||||
                                <input
 | 
			
		||||
                                        type="text"
 | 
			
		||||
                                        inputmode="numeric"
 | 
			
		||||
                                        pattern="\d*"
 | 
			
		||||
                                        id="emailCode"
 | 
			
		||||
                                        name="emailCode"
 | 
			
		||||
                                        required
 | 
			
		||||
                                />
 | 
			
		||||
                            </div>
 | 
			
		||||
 | 
			
		||||
                            <button
 | 
			
		||||
                                    id="emailCodeSendButton"
 | 
			
		||||
                                    type="button"
 | 
			
		||||
                                    th:text="#{form.emailCode.sendButton}"
 | 
			
		||||
                            ></button>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <p class="alert alert-error"
 | 
			
		||||
                           th:if="${#fields.hasErrors('emailCode')}"
 | 
			
		||||
                           th:errors="*{emailCode}">
 | 
			
		||||
                        </p>
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                <div class="form-item">
 | 
			
		||||
                    <label for="password" th:text="#{form.password.label}"></label>
 | 
			
		||||
                    <th:block
 | 
			
		||||
                            th:replace="~{gateway_modules/input_fragments :: password(id = 'password', name = 'password', required = 'true', minlength = 6, enableToggle = true)}"
 | 
			
		||||
                    ></th:block>
 | 
			
		||||
                    <p class="alert alert-error"
 | 
			
		||||
                       th:if="${#fields.hasErrors('password')}"
 | 
			
		||||
                       th:errors="*{password}">
 | 
			
		||||
                    </p>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                <div class="form-item">
 | 
			
		||||
                    <label for="confirmPassword" th:text="#{form.confirmPassword.label}"></label>
 | 
			
		||||
                    <th:block
 | 
			
		||||
                            th:replace="~{gateway_modules/input_fragments :: password(id = 'confirmPassword', name = null, required = 'true', minlength = 6, enableToggle = true)}"
 | 
			
		||||
                    ></th:block>
 | 
			
		||||
                    <p class="alert alert-error"
 | 
			
		||||
                       th:if="${#fields.hasErrors('confirmPassword')}"
 | 
			
		||||
                       th:errors="*{confirmPassword}">
 | 
			
		||||
                    </p>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                <div class="form-item">
 | 
			
		||||
                    <button type="submit" th:text="#{form.submit}"></button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </form>
 | 
			
		||||
 | 
			
		||||
            <div th:replace="~{gateway_modules/common_fragments::socialAuthProviders}"></div>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
    <script th:inline="javascript">
 | 
			
		||||
        document.addEventListener("DOMContentLoaded", function () {
 | 
			
		||||
            function sendRequest() {
 | 
			
		||||
                return new Promise((resolve, reject) => {
 | 
			
		||||
                    const email = document.getElementById("email").value;
 | 
			
		||||
 | 
			
		||||
                    if (!email) {
 | 
			
		||||
                        throw new Error("请先输入邮箱地址");
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    fetch("/signup/send-email-code", {
 | 
			
		||||
                        method: "POST",
 | 
			
		||||
                        body: JSON.stringify({ email: email }),
 | 
			
		||||
                        headers: {
 | 
			
		||||
                            "Content-Type": "application/json",
 | 
			
		||||
                            [[${_csrf.headerName}]]: [[${_csrf.token}]],
 | 
			
		||||
                        },
 | 
			
		||||
                    })
 | 
			
		||||
                        .then((response) => {
 | 
			
		||||
                            if (response.ok) {
 | 
			
		||||
                                resolve(response);
 | 
			
		||||
                            }
 | 
			
		||||
                            reject(response);
 | 
			
		||||
                        })
 | 
			
		||||
                        .catch((e) => {
 | 
			
		||||
                            reject(e);
 | 
			
		||||
                        });
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const emailCodeSendButton = document.getElementById("emailCodeSendButton");
 | 
			
		||||
            sendVerificationCode(emailCodeSendButton, sendRequest);
 | 
			
		||||
 | 
			
		||||
            var password = document.getElementById("password"),
 | 
			
		||||
                confirm_password = document.getElementById("confirmPassword");
 | 
			
		||||
 | 
			
		||||
            function validatePassword() {
 | 
			
		||||
                if (password.value != confirm_password.value) {
 | 
			
		||||
                    confirm_password.setCustomValidity("Passwords Don't Match");
 | 
			
		||||
                } else {
 | 
			
		||||
                    confirm_password.setCustomValidity("");
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            password.onchange = validatePassword;
 | 
			
		||||
            confirm_password.onkeyup = validatePassword;
 | 
			
		||||
        });
 | 
			
		||||
    </script>
 | 
			
		||||
</th:block>
 | 
			
		||||
</html>
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
title=注册
 | 
			
		||||
form.username.label=用户名
 | 
			
		||||
form.displayName.label=名称
 | 
			
		||||
form.email.label=电子邮箱
 | 
			
		||||
form.emailCode.label=邮箱验证码
 | 
			
		||||
form.emailCode.sendButton=发送
 | 
			
		||||
form.password.label=密码
 | 
			
		||||
form.confirmPassword.label=确认密码
 | 
			
		||||
form.submit=注册
 | 
			
		||||
 | 
			
		||||
error.invalid-email-code=无效的邮箱验证码
 | 
			
		||||
error.duplicate-name=用户名已经被注册
 | 
			
		||||
error.rate-limit-exceeded=请求过于频繁,请稍后再试
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
title=Sign up
 | 
			
		||||
form.username.label=Username
 | 
			
		||||
form.displayName.label=Display name
 | 
			
		||||
form.email.label=Email
 | 
			
		||||
form.emailCode.label=Email Code
 | 
			
		||||
form.emailCode.sendButton=Send
 | 
			
		||||
form.password.label=Password
 | 
			
		||||
form.confirmPassword.label=Confirm password
 | 
			
		||||
form.submit=Sign up
 | 
			
		||||
| 
						 | 
				
			
			@ -57,7 +57,7 @@ const handleLogout = () => {
 | 
			
		|||
        document.cookie =
 | 
			
		||||
          "XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
 | 
			
		||||
 | 
			
		||||
        router.replace({ name: "Login" });
 | 
			
		||||
        window.location.href = "/login";
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error("Failed to logout", error);
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,6 +14,7 @@ import { setupVueQuery } from "@/setup/setupVueQuery";
 | 
			
		|||
import { useGlobalInfoStore } from "@/stores/global-info";
 | 
			
		||||
import { useRoleStore } from "@/stores/role";
 | 
			
		||||
import { useUserStore } from "@/stores/user";
 | 
			
		||||
import { getCookie } from "@/utils/cookie";
 | 
			
		||||
import { hasPermission } from "@/utils/permission";
 | 
			
		||||
import {
 | 
			
		||||
  setupCoreModules,
 | 
			
		||||
| 
						 | 
				
			
			@ -78,8 +79,7 @@ async function initApp() {
 | 
			
		|||
    await userStore.fetchCurrentUser();
 | 
			
		||||
 | 
			
		||||
    // set locale
 | 
			
		||||
    i18n.global.locale.value =
 | 
			
		||||
      localStorage.getItem("locale") || getBrowserLanguage();
 | 
			
		||||
    i18n.global.locale.value = getCookie("language") || getBrowserLanguage();
 | 
			
		||||
 | 
			
		||||
    const globalInfoStore = useGlobalInfoStore();
 | 
			
		||||
    await globalInfoStore.fetchGlobalInfo();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
export function getCookie(name: string) {
 | 
			
		||||
  const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
 | 
			
		||||
  return match ? match[2] : null;
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -53,7 +53,7 @@ const handleLogout = () => {
 | 
			
		|||
        document.cookie =
 | 
			
		||||
          "XSRF-TOKEN=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;";
 | 
			
		||||
 | 
			
		||||
        window.location.href = "/console/login";
 | 
			
		||||
        window.location.href = "/login";
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error("Failed to logout", error);
 | 
			
		||||
      }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,6 +6,7 @@ import { setupVueQuery } from "@/setup/setupVueQuery";
 | 
			
		|||
import { useGlobalInfoStore } from "@/stores/global-info";
 | 
			
		||||
import { useRoleStore } from "@/stores/role";
 | 
			
		||||
import { useUserStore } from "@/stores/user";
 | 
			
		||||
import { getCookie } from "@/utils/cookie";
 | 
			
		||||
import { hasPermission } from "@/utils/permission";
 | 
			
		||||
import { consoleApiClient } from "@halo-dev/api-client";
 | 
			
		||||
import router from "@uc/router";
 | 
			
		||||
| 
						 | 
				
			
			@ -66,8 +67,7 @@ async function initApp() {
 | 
			
		|||
    await userStore.fetchCurrentUser();
 | 
			
		||||
 | 
			
		||||
    // set locale
 | 
			
		||||
    i18n.global.locale.value =
 | 
			
		||||
      localStorage.getItem("locale") || getBrowserLanguage();
 | 
			
		||||
    i18n.global.locale.value = getCookie("language") || getBrowserLanguage();
 | 
			
		||||
 | 
			
		||||
    const globalInfoStore = useGlobalInfoStore();
 | 
			
		||||
    await globalInfoStore.fetchGlobalInfo();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,7 +13,7 @@ export function setupAuthCheckGuard(router: Router) {
 | 
			
		|||
    const userStore = useUserStore();
 | 
			
		||||
 | 
			
		||||
    if (userStore.isAnonymous) {
 | 
			
		||||
      window.location.href = `/console/login?redirect_uri=${encodeURIComponent(
 | 
			
		||||
      window.location.href = `/login?redirect_uri=${encodeURIComponent(
 | 
			
		||||
        window.location.href
 | 
			
		||||
      )}`;
 | 
			
		||||
      return;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue