File size: 5,851 Bytes
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d432b
 
 
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
c9d432b
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d432b
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c9d432b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
092334a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// ---------------------------------------------------------------------------
// shell/LoginPage.tsx — X5: the branded door.
//
// THE CARD LANGUAGE IS `gate()`'s, DELIBERATELY (app.py:1228). Same lockup
// (mark + "Loopable" wordmark, side by side — wave-9 I5), same 410px card with
// ONE hairline closing all four sides (wave-10 I25a removed the gold top-rule:
// "there is an orange line at the top of the sign-in place… it's ugly" — do not
// reintroduce an accent edge here), same two fields and one primary button.
// Two doors into one product must not look like two products, and this is the
// door that outlives the other: `gate()` dies with app.py at EXIT-6.
//
// WHAT IT DOES NOT INHERIT: `gate()`'s `st.empty()` slot dance is a workaround
// for Streamlit replacing elements POSITIONALLY, labelled in its own docstring
// as something "the successor shell deletes". This is that successor. React
// unmounts what it unmounts; there is no ghost card to chase.
//
// NO CLIENT-SIDE BACKOFF (X5: the server owns it). The only local rule is
// single-flight — a second submit while one is in the air is dropped, which is
// double-click protection, not rate limiting.
// ---------------------------------------------------------------------------

import { useState } from "react";
import type { FormEvent } from "react";
import { Brand } from "./Brand";
import { login } from "./session";
import type { SessionUser } from "./session";

/** The reveal toggle the host's password field ships natively (BaseWeb's eye).
 *  Two doors, one affordance. */
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) {
      // No setBusy(false): the parent unmounts this tree on success, and
      // flipping state on the way out is how "update on an unmounted
      // component" warnings are born.
      onSignedIn(result.user);
      return;
    }
    setBusy(false);
    setError(result.message);
    setPassword("");
  }

  // An error that survives the correction is an error that reads as sticky.
  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>
  );
}