]> Untitled Git - lemmy-ui.git/blob - src/shared/components/home/signup.tsx
Fix password autocomplete (#2033)
[lemmy-ui.git] / src / shared / components / home / signup.tsx
1 import { myAuth, setIsoData } from "@utils/app";
2 import { isBrowser } from "@utils/browser";
3 import { validEmail } from "@utils/helpers";
4 import { Component, linkEvent } from "inferno";
5 import { T } from "inferno-i18next-dess";
6 import {
7   CaptchaResponse,
8   GetCaptchaResponse,
9   GetSiteResponse,
10   LoginResponse,
11   SiteView,
12 } from "lemmy-js-client";
13 import { joinLemmyUrl } from "../../config";
14 import { mdToHtml } from "../../markdown";
15 import { I18NextService, UserService } from "../../services";
16 import { HttpService, RequestState } from "../../services/HttpService";
17 import { toast } from "../../toast";
18 import { HtmlTags } from "../common/html-tags";
19 import { Icon, Spinner } from "../common/icon";
20 import { MarkdownTextArea } from "../common/markdown-textarea";
21 import PasswordInput from "../common/password-input";
22
23 interface State {
24   registerRes: RequestState<LoginResponse>;
25   captchaRes: RequestState<GetCaptchaResponse>;
26   form: {
27     username?: string;
28     email?: string;
29     password?: string;
30     password_verify?: string;
31     show_nsfw: boolean;
32     captcha_uuid?: string;
33     captcha_answer?: string;
34     honeypot?: string;
35     answer?: string;
36   };
37   captchaPlaying: boolean;
38   siteRes: GetSiteResponse;
39 }
40
41 export class Signup extends Component<any, State> {
42   private isoData = setIsoData(this.context);
43   private audio?: HTMLAudioElement;
44
45   state: State = {
46     registerRes: { state: "empty" },
47     captchaRes: { state: "empty" },
48     form: {
49       show_nsfw: false,
50     },
51     captchaPlaying: false,
52     siteRes: this.isoData.site_res,
53   };
54
55   constructor(props: any, context: any) {
56     super(props, context);
57
58     this.handleAnswerChange = this.handleAnswerChange.bind(this);
59   }
60
61   async componentDidMount() {
62     if (this.state.siteRes.site_view.local_site.captcha_enabled) {
63       await this.fetchCaptcha();
64     }
65   }
66
67   async fetchCaptcha() {
68     this.setState({ captchaRes: { state: "loading" } });
69     this.setState({
70       captchaRes: await HttpService.client.getCaptcha({}),
71     });
72
73     this.setState(s => {
74       if (s.captchaRes.state === "success") {
75         s.form.captcha_uuid = s.captchaRes.data.ok?.uuid;
76       }
77       return s;
78     });
79   }
80
81   get documentTitle(): string {
82     const siteView = this.state.siteRes.site_view;
83     return `${this.titleName(siteView)} - ${siteView.site.name}`;
84   }
85
86   titleName(siteView: SiteView): string {
87     return I18NextService.i18n.t(
88       siteView.local_site.private_instance ? "apply_to_join" : "sign_up",
89     );
90   }
91
92   get isLemmyMl(): boolean {
93     return isBrowser() && window.location.hostname === "lemmy.ml";
94   }
95
96   render() {
97     return (
98       <div className="home-signup container-lg">
99         <HtmlTags
100           title={this.documentTitle}
101           path={this.context.router.route.match.url}
102         />
103         <div className="row">
104           <div className="col-12 col-lg-6 offset-lg-3">
105             {this.registerForm()}
106           </div>
107         </div>
108       </div>
109     );
110   }
111
112   registerForm() {
113     const siteView = this.state.siteRes.site_view;
114     return (
115       <form
116         className="was-validated"
117         onSubmit={linkEvent(this, this.handleRegisterSubmit)}
118       >
119         <h1 className="h4 mb-4">{this.titleName(siteView)}</h1>
120
121         {this.isLemmyMl && (
122           <div className="mb-3 row">
123             <div className="mt-2 mb-0 alert alert-warning" role="alert">
124               <T i18nKey="lemmy_ml_registration_message">
125                 #<a href={joinLemmyUrl}>#</a>
126               </T>
127             </div>
128           </div>
129         )}
130
131         <div className="mb-3 row">
132           <label
133             className="col-sm-2 col-form-label"
134             htmlFor="register-username"
135           >
136             {I18NextService.i18n.t("username")}
137           </label>
138
139           <div className="col-sm-10">
140             <input
141               type="text"
142               id="register-username"
143               className="form-control"
144               value={this.state.form.username}
145               onInput={linkEvent(this, this.handleRegisterUsernameChange)}
146               required
147               minLength={3}
148               pattern="[a-zA-Z0-9_]+"
149               title={I18NextService.i18n.t("community_reqs")}
150             />
151           </div>
152         </div>
153
154         <div className="mb-3 row">
155           <label className="col-sm-2 col-form-label" htmlFor="register-email">
156             {I18NextService.i18n.t("email")}
157           </label>
158           <div className="col-sm-10">
159             <input
160               type="email"
161               id="register-email"
162               className="form-control"
163               placeholder={
164                 siteView.local_site.require_email_verification
165                   ? I18NextService.i18n.t("required")
166                   : I18NextService.i18n.t("optional")
167               }
168               value={this.state.form.email}
169               autoComplete="email"
170               onInput={linkEvent(this, this.handleRegisterEmailChange)}
171               required={siteView.local_site.require_email_verification}
172               minLength={3}
173             />
174             {!siteView.local_site.require_email_verification &&
175               this.state.form.email &&
176               !validEmail(this.state.form.email) && (
177                 <div className="mt-2 mb-0 alert alert-warning" role="alert">
178                   <Icon icon="alert-triangle" classes="icon-inline me-2" />
179                   {I18NextService.i18n.t("no_password_reset")}
180                 </div>
181               )}
182           </div>
183         </div>
184
185         <div className="mb-3">
186           <PasswordInput
187             id="register-password"
188             value={this.state.form.password}
189             onInput={linkEvent(this, this.handleRegisterPasswordChange)}
190             showStrength
191             label={I18NextService.i18n.t("password")}
192             isNew
193           />
194         </div>
195
196         <div className="mb-3">
197           <PasswordInput
198             id="register-verify-password"
199             value={this.state.form.password_verify}
200             onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
201             label={I18NextService.i18n.t("verify_password")}
202             isNew
203           />
204         </div>
205
206         {siteView.local_site.registration_mode === "RequireApplication" && (
207           <>
208             <div className="mb-3 row">
209               <div className="offset-sm-2 col-sm-10">
210                 <div className="mt-2 alert alert-warning" role="alert">
211                   <Icon icon="alert-triangle" classes="icon-inline me-2" />
212                   {I18NextService.i18n.t("fill_out_application")}
213                 </div>
214                 {siteView.local_site.application_question && (
215                   <div
216                     className="md-div"
217                     dangerouslySetInnerHTML={mdToHtml(
218                       siteView.local_site.application_question,
219                     )}
220                   />
221                 )}
222               </div>
223             </div>
224
225             <div className="mb-3 row">
226               <label
227                 className="col-sm-2 col-form-label"
228                 htmlFor="application_answer"
229               >
230                 {I18NextService.i18n.t("answer")}
231               </label>
232               <div className="col-sm-10">
233                 <MarkdownTextArea
234                   initialContent=""
235                   onContentChange={this.handleAnswerChange}
236                   hideNavigationWarnings
237                   allLanguages={[]}
238                   siteLanguages={[]}
239                 />
240               </div>
241             </div>
242           </>
243         )}
244         {this.renderCaptcha()}
245         <div className="mb-3 row">
246           <div className="col-sm-10">
247             <div className="form-check">
248               <input
249                 className="form-check-input"
250                 id="register-show-nsfw"
251                 type="checkbox"
252                 checked={this.state.form.show_nsfw}
253                 onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
254               />
255               <label className="form-check-label" htmlFor="register-show-nsfw">
256                 {I18NextService.i18n.t("show_nsfw")}
257               </label>
258             </div>
259           </div>
260         </div>
261         <input
262           tabIndex={-1}
263           autoComplete="false"
264           name="a_password"
265           type="text"
266           className="form-control honeypot"
267           id="register-honey"
268           value={this.state.form.honeypot}
269           onInput={linkEvent(this, this.handleHoneyPotChange)}
270         />
271         <div className="mb-3 row">
272           <div className="col-sm-10">
273             <button type="submit" className="btn btn-secondary">
274               {this.state.registerRes.state === "loading" ? (
275                 <Spinner />
276               ) : (
277                 this.titleName(siteView)
278               )}
279             </button>
280           </div>
281         </div>
282       </form>
283     );
284   }
285
286   renderCaptcha() {
287     switch (this.state.captchaRes.state) {
288       case "loading":
289         return <Spinner />;
290       case "success": {
291         const res = this.state.captchaRes.data;
292         return (
293           <div className="mb-3 row">
294             <label className="col-sm-2" htmlFor="register-captcha">
295               <span className="me-2">
296                 {I18NextService.i18n.t("enter_code")}
297               </span>
298               <button
299                 type="button"
300                 className="btn btn-secondary"
301                 onClick={linkEvent(this, this.handleRegenCaptcha)}
302                 aria-label={I18NextService.i18n.t("captcha")}
303               >
304                 <Icon icon="refresh-cw" classes="icon-refresh-cw" />
305               </button>
306             </label>
307             {this.showCaptcha(res)}
308             <div className="col-sm-6">
309               <input
310                 type="text"
311                 className="form-control"
312                 id="register-captcha"
313                 value={this.state.form.captcha_answer}
314                 onInput={linkEvent(
315                   this,
316                   this.handleRegisterCaptchaAnswerChange,
317                 )}
318                 required
319               />
320             </div>
321           </div>
322         );
323       }
324     }
325   }
326
327   showCaptcha(res: GetCaptchaResponse) {
328     const captchaRes = res?.ok;
329     return captchaRes ? (
330       <div className="col-sm-4">
331         <>
332           <img
333             className="rounded-top img-fluid"
334             src={this.captchaPngSrc(captchaRes)}
335             style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
336             alt={I18NextService.i18n.t("captcha")}
337           />
338           {captchaRes.wav && (
339             <button
340               className="rounded-bottom btn btn-sm btn-secondary d-block"
341               style="border-top-right-radius: 0; border-top-left-radius: 0;"
342               title={I18NextService.i18n.t("play_captcha_audio")}
343               onClick={linkEvent(this, this.handleCaptchaPlay)}
344               type="button"
345               disabled={this.state.captchaPlaying}
346             >
347               <Icon icon="play" classes="icon-play" />
348             </button>
349           )}
350         </>
351       </div>
352     ) : (
353       <></>
354     );
355   }
356
357   async handleRegisterSubmit(i: Signup, event: any) {
358     event.preventDefault();
359     const {
360       show_nsfw,
361       answer,
362       captcha_answer,
363       captcha_uuid,
364       email,
365       honeypot,
366       password,
367       password_verify,
368       username,
369     } = i.state.form;
370     if (username && password && password_verify) {
371       i.setState({ registerRes: { state: "loading" } });
372
373       const registerRes = await HttpService.client.register({
374         username,
375         password,
376         password_verify,
377         email,
378         show_nsfw,
379         captcha_uuid,
380         captcha_answer,
381         honeypot,
382         answer,
383       });
384       switch (registerRes.state) {
385         case "failed": {
386           toast(registerRes.msg, "danger");
387           i.setState({ registerRes: { state: "empty" } });
388           break;
389         }
390
391         case "success": {
392           const data = registerRes.data;
393
394           // Only log them in if a jwt was set
395           if (data.jwt) {
396             UserService.Instance.login({
397               res: data,
398             });
399
400             const site = await HttpService.client.getSite({ auth: myAuth() });
401
402             if (site.state === "success") {
403               UserService.Instance.myUserInfo = site.data.my_user;
404             }
405
406             i.props.history.replace("/communities");
407           } else {
408             if (data.verify_email_sent) {
409               toast(I18NextService.i18n.t("verify_email_sent"));
410             }
411             if (data.registration_created) {
412               toast(I18NextService.i18n.t("registration_application_sent"));
413             }
414             i.props.history.push("/");
415           }
416           break;
417         }
418       }
419     }
420   }
421
422   handleRegisterUsernameChange(i: Signup, event: any) {
423     i.state.form.username = event.target.value.trim();
424     i.setState(i.state);
425   }
426
427   handleRegisterEmailChange(i: Signup, event: any) {
428     i.state.form.email = event.target.value;
429     if (i.state.form.email === "") {
430       i.state.form.email = undefined;
431     }
432     i.setState(i.state);
433   }
434
435   handleRegisterPasswordChange(i: Signup, event: any) {
436     i.state.form.password = event.target.value;
437     i.setState(i.state);
438   }
439
440   handleRegisterPasswordVerifyChange(i: Signup, event: any) {
441     i.state.form.password_verify = event.target.value;
442     i.setState(i.state);
443   }
444
445   handleRegisterShowNsfwChange(i: Signup, event: any) {
446     i.state.form.show_nsfw = event.target.checked;
447     i.setState(i.state);
448   }
449
450   handleRegisterCaptchaAnswerChange(i: Signup, event: any) {
451     i.state.form.captcha_answer = event.target.value;
452     i.setState(i.state);
453   }
454
455   handleAnswerChange(val: string) {
456     this.setState(s => ((s.form.answer = val), s));
457   }
458
459   handleHoneyPotChange(i: Signup, event: any) {
460     i.state.form.honeypot = event.target.value;
461     i.setState(i.state);
462   }
463
464   async handleRegenCaptcha(i: Signup) {
465     i.audio = undefined;
466     i.setState({ captchaPlaying: false });
467     await i.fetchCaptcha();
468   }
469
470   handleCaptchaPlay(i: Signup) {
471     // This was a bad bug, it should only build the new audio on a new file.
472     // Replays would stop prematurely if this was rebuilt every time.
473
474     if (i.state.captchaRes.state === "success" && i.state.captchaRes.data.ok) {
475       const captchaRes = i.state.captchaRes.data.ok;
476       if (!i.audio) {
477         const base64 = `data:audio/wav;base64,${captchaRes.wav}`;
478         i.audio = new Audio(base64);
479         i.audio.play();
480
481         i.setState({ captchaPlaying: true });
482
483         i.audio.addEventListener("ended", () => {
484           if (i.audio) {
485             i.audio.currentTime = 0;
486             i.setState({ captchaPlaying: false });
487           }
488         });
489       }
490     }
491   }
492
493   captchaPngSrc(captcha: CaptchaResponse) {
494     return `data:image/png;base64,${captcha.png}`;
495   }
496 }