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