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