diff --git a/src/http.rs b/src/http.rs index 76a4ac5..406bcab 100644 --- a/src/http.rs +++ b/src/http.rs @@ -30,6 +30,7 @@ use crate::webauthn::WebAuthnService; const STYLE_CSS: &[u8] = include_bytes!("../static/style.css"); const PASSKEY_LOGIN_JS: &[u8] = include_bytes!("../static/passkey-login.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)] pub struct AppState { @@ -323,6 +324,7 @@ async fn static_asset(Path(path): Path) -> Response { "style.css" => ("text/css", STYLE_CSS), "passkey-login.js" => ("application/javascript", PASSKEY_LOGIN_JS), "passkey-register.js" => ("application/javascript", PASSKEY_REGISTER_JS), + "password-toggle.js" => ("application/javascript", PASSWORD_TOGGLE_JS), _ => return StatusCode::NOT_FOUND.into_response(), }; ([(header::CONTENT_TYPE, mime)], data).into_response() diff --git a/src/views.rs b/src/views.rs index 6571b0d..53eb9ef 100644 --- a/src/views.rs +++ b/src/views.rs @@ -25,13 +25,17 @@ pub fn login_page(error: Option<&str>, invite: Option<&str>) -> Markup { label for="email" { "Email" } input id="email" name="email" type="email" autocomplete="email" autofocus; label for="password" { "Password" } - input id="password" name="password" type="password" autocomplete="current-password" required; + div class="password-field" { + 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" } } div class="auth-divider" { span { "or" } } 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" } } } + script src="/static/password-toggle.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" } input id="email" name="email" type="email" autocomplete="email" required; label for="password" { "Password" } - input id="password" name="password" type="password" autocomplete="new-password" required; + div class="password-field" { + 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" } } 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" { input type="hidden" name="csrf" value=(csrf_token); label for="new-password" { "New password" } - input id="new-password" name="new_password" type="password" autocomplete="new-password" required; + div class="password-field" { + 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" } - input id="confirm-password" name="confirm_password" type="password" autocomplete="new-password" required; + div class="password-field" { + 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" } } } } + script src="/static/password-toggle.js" {} script src="/static/passkey-register.js" {} }, ) diff --git a/static/password-toggle.js b/static/password-toggle.js new file mode 100644 index 0000000..5cb4011 --- /dev/null +++ b/static/password-toggle.js @@ -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 }); + }); +}); diff --git a/static/style.css b/static/style.css index 7aea178..f90994c 100644 --- a/static/style.css +++ b/static/style.css @@ -75,6 +75,10 @@ h3 { margin-bottom: 6px; font-size: 1rem; } .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: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,"); 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 option { color: var(--ink); background: #fff; }