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}