Add frontend support for customizing login and logout pages

Signed-off-by: JohnNiang <johnniang@foxmail.com>
pull/6488/head
Ryan Wang 2024-09-28 17:38:32 +08:00 committed by JohnNiang
parent 1f9610686b
commit 8547ffe613
40 changed files with 1380 additions and 7 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -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

View File

@ -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";
}
});
}
});
});

View File

@ -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;
}
}

View File

@ -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>

View File

@ -0,0 +1,4 @@
title=两步验证
messages.invalidError=错误的验证码
form.code.label=验证码
form.submit=验证

View File

@ -0,0 +1,4 @@
title=Two-Factor Authentication
messages.invalidError=Invalid TOTP code
form.code.label=TOTP Code
form.submit=Verify

View File

@ -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>

View File

@ -0,0 +1 @@
socialLogin.label=社交登录

View File

@ -0,0 +1 @@
socialLogin.label=Social Login

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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=返回网站

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1 @@
title=登录

View File

@ -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>

View File

@ -0,0 +1 @@
title=Login

View File

@ -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>

View File

@ -0,0 +1,3 @@
form.username.label=用户名
form.password.label=密码
form.password.forgot=忘记密码?

View File

@ -0,0 +1,3 @@
form.username.label=Username
form.password.label=Password
form.password.forgot=Forgot your password?

View File

@ -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>

View File

@ -0,0 +1,3 @@
title=退出登录
form.title=确定要退出登录吗?
form.submit=退出登录

View File

@ -0,0 +1,3 @@
title=Logout
form.title=Are you sure want to log out?
form.submit=Logout

View File

@ -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>

View File

@ -0,0 +1,5 @@
title=为 {0} 修改密码
form.password.label=密码
form.confirmPassword.label=确认密码
form.password.tips=密码必须至少包含 8 个字符,并且至少包含一个大写字母、一个小写字母、一个数字和一个特殊字符。
form.submit=修改密码

View File

@ -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

View File

@ -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>

View File

@ -0,0 +1,6 @@
title=重置密码
form.email.label=电子邮箱
form.submit=提交
sent.form.submit=返回到登录页面
sent.form.message=检查您的电子邮件中是否有重置密码的链接。如果几分钟内没有出现,请检查您的垃圾邮件文件夹。
sent.title=已发送重置密码的邮件

View File

@ -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 doesnt appear within a few minutes, check your spam folder.
sent.title=Password reset email has been sent

View File

@ -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>

View File

@ -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=请求过于频繁,请稍后再试

View File

@ -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

View File

@ -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);
}

View File

@ -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();

4
ui/src/utils/cookie.ts Normal file
View File

@ -0,0 +1,4 @@
export function getCookie(name: string) {
const match = document.cookie.match(new RegExp("(^| )" + name + "=([^;]+)"));
return match ? match[2] : null;
}

View File

@ -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);
}

View File

@ -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();

View File

@ -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;