show/hide password button

This commit is contained in:
2026-08-03 21:02:03 -04:00
parent fdbf40adac
commit cf6853d71e
4 changed files with 41 additions and 4 deletions
+2
View File
@@ -30,6 +30,7 @@ use crate::webauthn::WebAuthnService;
const STYLE_CSS: &[u8] = include_bytes!("../static/style.css"); const STYLE_CSS: &[u8] = include_bytes!("../static/style.css");
const PASSKEY_LOGIN_JS: &[u8] = include_bytes!("../static/passkey-login.js"); const PASSKEY_LOGIN_JS: &[u8] = include_bytes!("../static/passkey-login.js");
const PASSKEY_REGISTER_JS: &[u8] = include_bytes!("../static/passkey-register.js"); const PASSKEY_REGISTER_JS: &[u8] = include_bytes!("../static/passkey-register.js");
const PASSWORD_TOGGLE_JS: &[u8] = include_bytes!("../static/password-toggle.js");
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
@@ -323,6 +324,7 @@ async fn static_asset(Path(path): Path<String>) -> Response {
"style.css" => ("text/css", STYLE_CSS), "style.css" => ("text/css", STYLE_CSS),
"passkey-login.js" => ("application/javascript", PASSKEY_LOGIN_JS), "passkey-login.js" => ("application/javascript", PASSKEY_LOGIN_JS),
"passkey-register.js" => ("application/javascript", PASSKEY_REGISTER_JS), "passkey-register.js" => ("application/javascript", PASSKEY_REGISTER_JS),
"password-toggle.js" => ("application/javascript", PASSWORD_TOGGLE_JS),
_ => return StatusCode::NOT_FOUND.into_response(), _ => return StatusCode::NOT_FOUND.into_response(),
}; };
([(header::CONTENT_TYPE, mime)], data).into_response() ([(header::CONTENT_TYPE, mime)], data).into_response()
+15
View File
@@ -25,13 +25,17 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup {
label for="email" { "Email" } label for="email" { "Email" }
input id="email" name="email" type="email" autocomplete="email" autofocus; input id="email" name="email" type="email" autocomplete="email" autofocus;
label for="password" { "Password" } label for="password" { "Password" }
div class="password-field" {
input id="password" name="password" type="password" autocomplete="current-password" required; input id="password" name="password" type="password" autocomplete="current-password" required;
button class="password-toggle" type="button" data-toggle-for="password" aria-label="Show password" { "Show" }
}
button class="button button-primary" type="submit" { "Sign in" } button class="button button-primary" type="submit" { "Sign in" }
} }
div class="auth-divider" { span { "or" } } div class="auth-divider" { span { "or" } }
button id="passkey-login" class="button button-secondary" type="button" { "Sign in with a passkey" } button id="passkey-login" class="button button-secondary" type="button" { "Sign in with a passkey" }
p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } } p class="auth-switch" { "Need an account? " a href="/register" { "Create one" } }
} }
script src="/static/password-toggle.js" {}
script src="/static/passkey-login.js" {} script src="/static/passkey-login.js" {}
}, },
) )
@@ -58,11 +62,15 @@ pub fn register_page(error: Option<&str>, invite: Option<&str>) -> Markup {
label for="email" { "Email" } label for="email" { "Email" }
input id="email" name="email" type="email" autocomplete="email" required; input id="email" name="email" type="email" autocomplete="email" required;
label for="password" { "Password" } label for="password" { "Password" }
div class="password-field" {
input id="password" name="password" type="password" autocomplete="new-password" required; input id="password" name="password" type="password" autocomplete="new-password" required;
button class="password-toggle" type="button" data-toggle-for="password" aria-label="Show password" { "Show" }
}
button class="button button-primary" type="submit" { "Create account" } button class="button button-primary" type="submit" { "Create account" }
} }
p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } } p class="auth-switch" { "Already have an account? " a href="/login" { "Sign in" } }
} }
script src="/static/password-toggle.js" {}
}, },
) )
} }
@@ -141,13 +149,20 @@ pub fn account_page(
form method="post" action="/account/password" class="stack" { form method="post" action="/account/password" class="stack" {
input type="hidden" name="csrf" value=(csrf_token); input type="hidden" name="csrf" value=(csrf_token);
label for="new-password" { "New password" } label for="new-password" { "New password" }
div class="password-field" {
input id="new-password" name="new_password" type="password" autocomplete="new-password" required; input id="new-password" name="new_password" type="password" autocomplete="new-password" required;
button class="password-toggle" type="button" data-toggle-for="new-password" aria-label="Show password" { "Show" }
}
label for="confirm-password" { "Confirm new password" } label for="confirm-password" { "Confirm new password" }
div class="password-field" {
input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required; input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required;
button class="password-toggle" type="button" data-toggle-for="confirm-password" aria-label="Show password" { "Show" }
}
button class="button button-primary" type="submit" { "Update password" } button class="button button-primary" type="submit" { "Update password" }
} }
} }
} }
script src="/static/password-toggle.js" {}
script src="/static/passkey-register.js" {} script src="/static/passkey-register.js" {}
}, },
) )
+16
View File
@@ -0,0 +1,16 @@
// Toggle password visibility so users can check for typos, especially on mobile.
document.querySelectorAll(".password-toggle").forEach(function (button) {
button.addEventListener("pointerdown", function (event) {
// Toggle on press (not click) for an instant response. preventScroll avoids
// a scroll-to-input animation that makes rapid toggling feel laggy, and
// keeping focus on the input keeps the mobile keyboard open.
event.preventDefault();
var input = document.getElementById(button.getAttribute("data-toggle-for"));
if (!input) return;
var showing = input.type === "text";
input.type = showing ? "password" : "text";
button.textContent = showing ? "Show" : "Hide";
button.setAttribute("aria-label", showing ? "Show password" : "Hide password");
input.focus({ preventScroll: true });
});
});
+4
View File
@@ -75,6 +75,10 @@ h3 { margin-bottom: 6px; font-size: 1rem; }
.stack label { color: var(--muted); font-size: .82rem; font-weight: 700; } .stack label { color: var(--muted); font-size: .82rem; font-weight: 700; }
input { width: 100%; min-height: 46px; padding: 10px 13px; border: 1px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; } input { width: 100%; min-height: 46px; padding: 10px 13px; border: 1px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; }
input:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113, 93, .12); } input:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113, 93, .12); }
.password-field { position: relative; }
.password-field input { padding-right: 64px; }
.password-toggle { position: absolute; top: 50%; right: 8px; transform: translateY(-50%); min-height: 32px; padding: 5px 10px; border: 0; border-radius: 9px; cursor: pointer; color: var(--deep-sage); background: #e7f0e1; font-weight: 800; font-size: .78rem; }
.password-toggle:hover { background: #dbe9d2; }
select { width: 100%; min-height: 46px; padding: 10px 34px 10px 13px; border: 1px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; font: inherit; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M4 6l4 4 4-4' fill='none' stroke='%2355715d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/></svg>"); background-repeat: no-repeat; background-position: right 12px center; } select { width: 100%; min-height: 46px; padding: 10px 34px 10px 13px; border: 1px solid var(--line); border-radius: 12px; outline: none; color: var(--ink); background: #fff; font: inherit; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16'><path d='M4 6l4 4 4-4' fill='none' stroke='%2355715d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/></svg>"); background-repeat: no-repeat; background-position: right 12px center; }
select:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113, 93, .12); } select:focus { border-color: var(--deep-sage); box-shadow: 0 0 0 4px rgba(85, 113, 93, .12); }
select option { color: var(--ink); background: #fff; } select option { color: var(--ink); background: #fff; }