| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { useState } from "react"; |
| import type { FormEvent } from "react"; |
| import { Brand } from "./Brand"; |
| import { login } from "./session"; |
| import type { SessionUser } from "./session"; |
|
|
| |
| |
| function EyeIcon({ off }: { off: boolean }) { |
| return ( |
| <svg viewBox="0 0 20 20" aria-hidden="true"> |
| <path d="M2.2 10s2.8-4.6 7.8-4.6S17.8 10 17.8 10 15 14.6 10 14.6 2.2 10 2.2 10z" /> |
| <circle cx="10" cy="10" r="2.3" /> |
| {off ? <path d="M4 16 16 4" /> : null} |
| </svg> |
| ); |
| } |
|
|
| export default function LoginPage({ onSignedIn }: { onSignedIn: (user: SessionUser) => void }) { |
| const [username, setUsername] = useState(""); |
| const [password, setPassword] = useState(""); |
| const [error, setError] = useState(""); |
| const [busy, setBusy] = useState(false); |
| const [reveal, setReveal] = useState(false); |
|
|
| async function submit(e: FormEvent) { |
| e.preventDefault(); |
| if (busy) return; |
| setBusy(true); |
| setError(""); |
| const result = await login(username, password); |
| if (result.ok) { |
| |
| |
| |
| onSignedIn(result.user); |
| return; |
| } |
| setBusy(false); |
| setError(result.message); |
| setPassword(""); |
| } |
|
|
| |
| function edit(set: (v: string) => void) { |
| return (e: { target: { value: string } }) => { |
| if (error) setError(""); |
| set(e.target.value); |
| }; |
| } |
|
|
| return ( |
| <div className="login-root"> |
| <div className="login-card"> |
| <Brand size={44} className="login-brand" /> |
| |
| <form className="login-form" onSubmit={submit} noValidate> |
| {/* Labels are present and hidden, not absent. `gate()` collapses them |
| because Streamlit gives no other way to get a placeholder-only |
| field; here the label exists for assistive tech and the visual is |
| identical. */} |
| {/* No autoFocus: the other door cannot autofocus (Streamlit), and the |
| focus ring it would paint on load reads as a highlighted error on |
| an untouched form. */} |
| <label className="lp-sr-only" htmlFor="login-username">Username</label> |
| <input |
| id="login-username" |
| className="login-input" |
| name="username" |
| type="text" |
| autoComplete="username" |
| placeholder="Username" |
| value={username} |
| onChange={edit(setUsername)} |
| disabled={busy} |
| aria-invalid={error ? true : undefined} |
| /> |
| |
| <label className="lp-sr-only" htmlFor="login-password">Password</label> |
| <div className="login-field"> |
| <input |
| id="login-password" |
| className="login-input" |
| name="password" |
| type={reveal ? "text" : "password"} |
| autoComplete="current-password" |
| placeholder="Password" |
| value={password} |
| onChange={edit(setPassword)} |
| disabled={busy} |
| aria-invalid={error ? true : undefined} |
| /> |
| {/* tabIndex −1: Tab goes Username → Password → Log in; the reveal is |
| a mouse affordance, same as the host's. */} |
| <button |
| type="button" |
| className="login-eye" |
| aria-label={reveal ? "Hide password" : "Show password"} |
| onClick={() => setReveal((v) => !v)} |
| disabled={busy} |
| tabIndex={-1} |
| > |
| <EyeIcon off={reveal} /> |
| </button> |
| </div> |
| |
| {/* Disabled through THREE channels (the real attribute, the cursor and |
| the opacity), never opacity alone — a control that only looks dead |
| is a control that still takes the click. */} |
| <button className="login-submit" type="submit" disabled={busy}> |
| {busy ? "Signing in…" : "Log in"} |
| </button> |
| </form> |
| |
| {/* The message line is ALWAYS in the layout (min-height: 1lh) so an |
| arriving error does not shove the card. `role="status"` rather than |
| "alert": polite announcement, no interruption. */} |
| <p className="login-msg" role="status" aria-live="polite"> |
| {error} |
| </p> |
| </div> |
| </div> |
| ); |
| } |
|
|