BT  ·  2026-06-08

Login.res

  1open Preact
  2
  3let errors: Preact.Signal.t<option<string>> = Signal.make(None)
  4let signup = Signal.make(false)
  5let submitting: Preact.Signal.t<bool> = Signal.make(false)
  6
  7let get = (selector: string): option<string> => {
  8  switch Doc.getFormElement(selector)->Nullable.toOption {
  9  | Some(elem) if elem.value != Nullable.undefined =>
 10    switch elem.value->Nullable.toOption {
 11    | Some(v) if v != "" => Some(v)
 12    | _ => None
 13    }
 14  | _ => None
 15  }
 16}
 17
 18type validationResult =
 19  | Valid
 20  | Invalid(string)
 21
 22// Pure validation: login form requires both user and password
 23let validateLoginForm = (~user: option<string>, ~password: option<string>): validationResult => {
 24  switch (user, password) {
 25  | (Some(_), Some(_)) => Valid
 26  | (None, Some(_)) => Invalid("Username or handle is required")
 27  | (Some(_), None) => Invalid("Password is required")
 28  | (None, None) => Invalid("Username and password are required")
 29  }
 30}
 31
 32// Pure validation: signup form requires (email OR handle) AND password
 33let validateSignupForm = (
 34  ~user: option<string>,
 35  ~handle: option<string>,
 36  ~password: option<string>,
 37): validationResult => {
 38  switch ((user, handle), password) {
 39  | ((Some(_), _) | (_, Some(_)), Some(_)) => Valid
 40  | ((None, None), _) => Invalid("A handle or an email and a password are required")
 41  | (_, None) => Invalid("A handle or an email and a password are required")
 42  }
 43}
 44
 45// Parse the server's 200 OK user JSON into State.user
 46let parseUserFromJSON = (json: JSON.t): option<State.user> => {
 47  switch json->JSON.Decode.object {
 48  | Some(dict) => {
 49      let id = dict->Dict.get("id")->Option.flatMap(JSON.Decode.string)
 50      let email = dict->Dict.get("email")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
 51      let handle = dict->Dict.get("handle")->Option.flatMap(JSON.Decode.string)->Option.getOr("")
 52      let registered =
 53        dict->Dict.get("registered")->Option.flatMap(JSON.Decode.bool)->Option.getOr(false)
 54
 55      switch id {
 56      | Some(id) => Some({id, email, handle, registered})
 57      | None => None
 58      }
 59    }
 60  | None => None
 61  }
 62}
 63
 64let submit = async () => {
 65  errors->Signal.set(None)
 66  submitting->Signal.set(true)
 67
 68  let user = get("#user")
 69  let pwd = get("#password")
 70
 71  let validation = if signup->Signal.get {
 72    let handle = get("#handle")
 73    validateSignupForm(~user, ~handle, ~password=pwd)
 74  } else {
 75    validateLoginForm(~user, ~password=pwd)
 76  }
 77
 78  switch validation {
 79  | Invalid(msg) => {
 80      errors->Signal.set(Some(msg))
 81      submitting->Signal.set(false)
 82    }
 83  | Valid => {
 84      let url = signup->Signal.get ? "/signup" : "/login"
 85      let bodyDict = Dict.make()
 86      bodyDict->Dict.set("user", JSON.Encode.string(user->Option.getOr("")))
 87      bodyDict->Dict.set("password", JSON.Encode.string(pwd->Option.getOr("")))
 88      if signup->Signal.get {
 89        let handle = get("#handle")
 90        bodyDict->Dict.set("handle", JSON.Encode.string(handle->Option.getOr("")))
 91      }
 92      let body = JSON.Encode.object(bodyDict)
 93
 94      let _result = await Fetch.post(url)
 95      ->Fetch.setBody(body)
 96      ->Fetch.credentials(Include)
 97      ->Fetch.onResponse(
 98        ~onSuccess=json => {
 99          submitting->Signal.set(false)
100          switch parseUserFromJSON(json) {
101          | Some(user) => {
102              State.user->Signal.set(Some(user))
103              State.content->Signal.set(None)
104            }
105          | None => errors->Signal.set(Some("Invalid response from server"))
106          }
107        },
108        ~onError=(status, msg) => {
109          submitting->Signal.set(false)
110          errors->Signal.set(Some(msg))
111          Console.error2("Login/signup failed", (status, msg))
112        },
113      )
114      ->Fetch.send
115    }
116  }
117}
118let fields = "bg-white border border-gray-300 text-gray-900 rounded-lg focus:ring-[var(--color-orange)] focus:border-[var(--color-orange)] block w-full p-2.5"
119
120@jsx.component
121let make = () => {
122  <div className="flex flex-col p-10 md:p-15">
123    <Content.CloseButton />
124    <div className="space-y-4 md:space-y-6">
125      <div className="">
126        <label
127          htmlFor="user"
128          className={cls("block text-sm font-medium", dict{"mb-2": !(signup->Signal.get)})}
129        >
130          {signup->Signal.get ? {"Email"->string} : {"Email or Handle"->string}}
131        </label>
132        {signup->Signal.get
133          ? <div className="text-xs text-gray-700 italic leading-none mb-2">
134              {"Email is optional. Without it you can't reset your password."->string}
135            </div>
136          : Preact.null}
137        <input
138          type_="text"
139          name="user"
140          id="user"
141          autoCapitalize="off"
142          autoComplete="off"
143          className={fields}
144        />
145      </div>
146      {signup->Signal.get
147        ? <div className="">
148            <label htmlFor="handle" className="block text-sm font-medium">
149              {"Handle"->string}
150            </label>
151            <div className="text-xs text-gray-700 italic leading-none mb-2">
152              <span>
153                {"Your handle will appear on your public routes. You can change it later but it must be unique. Allowed characters:"->string}
154              </span>
155              <span className="not-italic font-mono">
156                {" A-Z a-z 0-9 . _  @ - [] ! "->string}
157              </span>
158              <span> {"and at least 3 letters long."->string} </span>
159            </div>
160            <input
161              name="handle" id="handle" autoCapitalize="off" autoComplete="off" className={fields}
162            />
163          </div>
164        : Preact.null}
165      <div className="">
166        <label htmlFor="password" className="block mb-2 text-sm font-medium">
167          {"Password"->string}
168        </label>
169        <input
170          type_="password"
171          name="password"
172          id="password"
173          autoCapitalize="off"
174          autoComplete="off"
175          className={fields}
176        />
177      </div>
178      <div className="">
179        <button
180          className="w-full text-white bg-[var(--color-orange)] focus:ring-4 focus:outline-none focus:ring-[var(--color-blue)] font-medium rounded-lg text-sm px-5 py-2.5 disabled:opacity-50 disabled:cursor-not-allowed"
181          onClick={_evt => {
182            let _ = submit()
183          }}
184          disabled={submitting->Signal.get}
185        >
186          {switch (signup->Signal.get, submitting->Signal.get) {
187          | (true, true) => "Submitting..."->string
188          | (true, false) => "Sign up"->string
189          | (false, true) => "Submitting..."->string
190          | (false, false) => "Login"->string
191          }}
192        </button>
193      </div>
194      {switch errors->Signal.get {
195      | Some(err) =>
196        <div className="bg-red-300 text-[var(--color-red)] px-4 py-2 rounded-sm">
197          {err->string}
198        </div>
199      | None => Preact.null
200      }}
201      {signup->Signal.get
202        ? <div className="">
203            {"Already have an account? "->string}
204            <a
205              role="button"
206              className="text-[var(--color-blue)] cursor-pointer underline decoration-dotted"
207              onClick={_evt => signup->Signal.set(false)}
208            >
209              {"Login"->string}
210            </a>
211          </div>
212        : <div className="">
213            {"Don't have an account? "->string}
214            <a
215              role="button"
216              className="text-[var(--color-blue)] cursor-pointer underline decoration-dotted"
217              onClick={_evt => signup->Signal.set(true)}
218            >
219              {"Sign up"->string}
220            </a>
221          </div>}
222    </div>
223  </div>
224}