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