]> Untitled Git - lemmy-ui.git/blob - src/shared/components/home/signup.tsx
2f6285033f620ae1cffa61c96116cf3d18645b87
[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     let siteView = this.state.siteRes.site_view;
115     return `${this.titleName(siteView)} - ${siteView.site.name}`;
116   }
117
118   titleName(siteView: SiteView): string {
119     return i18n.t(
120       siteView.local_site.private_instance ? "apply_to_join" : "sign_up"
121     );
122   }
123
124   get isLemmyMl(): boolean {
125     return isBrowser() && window.location.hostname == "lemmy.ml";
126   }
127
128   render() {
129     return (
130       <div className="container-lg">
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     let siteView = this.state.siteRes.site_view;
148     return (
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.local_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.local_site.require_email_verification}
203               minLength={3}
204             />
205             {!siteView.local_site.require_email_verification &&
206               !this.state.registerForm.email.map(validEmail).unwrapOr(true) && (
207                 <div className="mt-2 mb-0 alert alert-warning" role="alert">
208                   <Icon icon="alert-triangle" classes="icon-inline mr-2" />
209                   {i18n.t("no_password_reset")}
210                 </div>
211               )}
212           </div>
213         </div>
214
215         <div className="form-group row">
216           <label
217             className="col-sm-2 col-form-label"
218             htmlFor="register-password"
219           >
220             {i18n.t("password")}
221           </label>
222           <div className="col-sm-10">
223             <input
224               type="password"
225               id="register-password"
226               value={this.state.registerForm.password}
227               autoComplete="new-password"
228               onInput={linkEvent(this, this.handleRegisterPasswordChange)}
229               minLength={10}
230               maxLength={60}
231               className="form-control"
232               required
233             />
234             {this.state.registerForm.password && (
235               <div className={this.passwordColorClass}>
236                 {i18n.t(this.passwordStrength as I18nKeys)}
237               </div>
238             )}
239           </div>
240         </div>
241
242         <div className="form-group row">
243           <label
244             className="col-sm-2 col-form-label"
245             htmlFor="register-verify-password"
246           >
247             {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.registerForm.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.require_application && (
264           <>
265             <div className="form-group 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 mr-2" />
269                   {i18n.t("fill_out_application")}
270                 </div>
271                 {siteView.local_site.application_question.match({
272                   some: question => (
273                     <div
274                       className="md-div"
275                       dangerouslySetInnerHTML={mdToHtml(question)}
276                     />
277                   ),
278                   none: <></>,
279                 })}
280               </div>
281             </div>
282
283             <div className="form-group row">
284               <label
285                 className="col-sm-2 col-form-label"
286                 htmlFor="application_answer"
287               >
288                 {i18n.t("answer")}
289               </label>
290               <div className="col-sm-10">
291                 <MarkdownTextArea
292                   initialContent={None}
293                   initialLanguageId={None}
294                   placeholder={None}
295                   buttonTitle={None}
296                   maxLength={None}
297                   onContentChange={this.handleAnswerChange}
298                   hideNavigationWarnings
299                   allLanguages={[]}
300                 />
301               </div>
302             </div>
303           </>
304         )}
305
306         {this.state.captcha.isSome() && (
307           <div className="form-group row">
308             <label className="col-sm-2" htmlFor="register-captcha">
309               <span className="mr-2">{i18n.t("enter_code")}</span>
310               <button
311                 type="button"
312                 className="btn btn-secondary"
313                 onClick={linkEvent(this, this.handleRegenCaptcha)}
314                 aria-label={i18n.t("captcha")}
315               >
316                 <Icon icon="refresh-cw" classes="icon-refresh-cw" />
317               </button>
318             </label>
319             {this.showCaptcha()}
320             <div className="col-sm-6">
321               <input
322                 type="text"
323                 className="form-control"
324                 id="register-captcha"
325                 value={toUndefined(this.state.registerForm.captcha_answer)}
326                 onInput={linkEvent(
327                   this,
328                   this.handleRegisterCaptchaAnswerChange
329                 )}
330                 required
331               />
332             </div>
333           </div>
334         )}
335         {siteView.local_site.enable_nsfw && (
336           <div className="form-group row">
337             <div className="col-sm-10">
338               <div className="form-check">
339                 <input
340                   className="form-check-input"
341                   id="register-show-nsfw"
342                   type="checkbox"
343                   checked={this.state.registerForm.show_nsfw}
344                   onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
345                 />
346                 <label
347                   className="form-check-label"
348                   htmlFor="register-show-nsfw"
349                 >
350                   {i18n.t("show_nsfw")}
351                 </label>
352               </div>
353             </div>
354           </div>
355         )}
356         <input
357           tabIndex={-1}
358           autoComplete="false"
359           name="a_password"
360           type="text"
361           className="form-control honeypot"
362           id="register-honey"
363           value={toUndefined(this.state.registerForm.honeypot)}
364           onInput={linkEvent(this, this.handleHoneyPotChange)}
365         />
366         <div className="form-group row">
367           <div className="col-sm-10">
368             <button type="submit" className="btn btn-secondary">
369               {this.state.registerLoading ? (
370                 <Spinner />
371               ) : (
372                 this.titleName(siteView)
373               )}
374             </button>
375           </div>
376         </div>
377       </form>
378     );
379   }
380
381   showCaptcha() {
382     return this.state.captcha.match({
383       some: captcha => (
384         <div className="col-sm-4">
385           {captcha.ok.match({
386             some: res => (
387               <>
388                 <img
389                   className="rounded-top img-fluid"
390                   src={this.captchaPngSrc(res)}
391                   style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
392                   alt={i18n.t("captcha")}
393                 />
394                 {res.wav.isSome() && (
395                   <button
396                     className="rounded-bottom btn btn-sm btn-secondary btn-block"
397                     style="border-top-right-radius: 0; border-top-left-radius: 0;"
398                     title={i18n.t("play_captcha_audio")}
399                     onClick={linkEvent(this, this.handleCaptchaPlay)}
400                     type="button"
401                     disabled={this.state.captchaPlaying}
402                   >
403                     <Icon icon="play" classes="icon-play" />
404                   </button>
405                 )}
406               </>
407             ),
408             none: <></>,
409           })}
410         </div>
411       ),
412       none: <></>,
413     });
414   }
415
416   get passwordStrength() {
417     return passwordStrength(
418       this.state.registerForm.password,
419       passwordStrengthOptions
420     ).value;
421   }
422
423   get passwordColorClass(): string {
424     let strength = this.passwordStrength;
425
426     if (["weak", "medium"].includes(strength)) {
427       return "text-warning";
428     } else if (strength == "strong") {
429       return "text-success";
430     } else {
431       return "text-danger";
432     }
433   }
434
435   handleRegisterSubmit(i: Signup, event: any) {
436     event.preventDefault();
437     i.setState({ registerLoading: true });
438     WebSocketService.Instance.send(wsClient.register(i.state.registerForm));
439   }
440
441   handleRegisterUsernameChange(i: Signup, event: any) {
442     i.state.registerForm.username = event.target.value;
443     i.setState(i.state);
444   }
445
446   handleRegisterEmailChange(i: Signup, event: any) {
447     i.state.registerForm.email = Some(event.target.value);
448     if (i.state.registerForm.email.unwrap() == "") {
449       i.state.registerForm.email = None;
450     }
451     i.setState(i.state);
452   }
453
454   handleRegisterPasswordChange(i: Signup, event: any) {
455     i.state.registerForm.password = event.target.value;
456     i.setState(i.state);
457   }
458
459   handleRegisterPasswordVerifyChange(i: Signup, event: any) {
460     i.state.registerForm.password_verify = event.target.value;
461     i.setState(i.state);
462   }
463
464   handleRegisterShowNsfwChange(i: Signup, event: any) {
465     i.state.registerForm.show_nsfw = event.target.checked;
466     i.setState(i.state);
467   }
468
469   handleRegisterCaptchaAnswerChange(i: Signup, event: any) {
470     i.state.registerForm.captcha_answer = Some(event.target.value);
471     i.setState(i.state);
472   }
473
474   handleAnswerChange(val: string) {
475     this.setState(s => ((s.registerForm.answer = Some(val)), s));
476   }
477
478   handleHoneyPotChange(i: Signup, event: any) {
479     i.state.registerForm.honeypot = Some(event.target.value);
480     i.setState(i.state);
481   }
482
483   handleRegenCaptcha(i: Signup) {
484     i.audio = null;
485     i.setState({ captchaPlaying: false });
486     WebSocketService.Instance.send(wsClient.getCaptcha());
487   }
488
489   handleCaptchaPlay(i: Signup) {
490     // This was a bad bug, it should only build the new audio on a new file.
491     // Replays would stop prematurely if this was rebuilt every time.
492     i.state.captcha.match({
493       some: captcha =>
494         captcha.ok.match({
495           some: res => {
496             if (i.audio == null) {
497               let base64 = `data:audio/wav;base64,${res.wav}`;
498               i.audio = new Audio(base64);
499             }
500
501             i.audio.play();
502
503             i.setState({ captchaPlaying: true });
504
505             i.audio.addEventListener("ended", () => {
506               i.audio.currentTime = 0;
507               i.setState({ captchaPlaying: false });
508             });
509           },
510           none: void 0,
511         }),
512       none: void 0,
513     });
514   }
515
516   captchaPngSrc(captcha: CaptchaResponse) {
517     return `data:image/png;base64,${captcha.png}`;
518   }
519
520   parseMessage(msg: any) {
521     let op = wsUserOp(msg);
522     console.log(msg);
523     if (msg.error) {
524       toast(i18n.t(msg.error), "danger");
525       this.setState(this.emptyState);
526       this.setState(s => ((s.registerForm.captcha_answer = undefined), s));
527       // Refetch another captcha
528       // WebSocketService.Instance.send(wsClient.getCaptcha());
529       return;
530     } else {
531       if (op == UserOperation.Register) {
532         let data = wsJsonToRes<LoginResponse>(msg, LoginResponse);
533         this.setState(this.emptyState);
534         // Only log them in if a jwt was set
535         if (data.jwt.isSome()) {
536           UserService.Instance.login(data);
537           this.props.history.push("/communities");
538           location.reload();
539         } else {
540           if (data.verify_email_sent) {
541             toast(i18n.t("verify_email_sent"));
542           }
543           if (data.registration_created) {
544             toast(i18n.t("registration_application_sent"));
545           }
546           this.props.history.push("/");
547         }
548       } else if (op == UserOperation.GetCaptcha) {
549         let data = wsJsonToRes<GetCaptchaResponse>(msg, GetCaptchaResponse);
550         data.ok.match({
551           some: res => {
552             this.setState({ captcha: Some(data) });
553             this.setState(
554               s => ((s.registerForm.captcha_uuid = Some(res.uuid)), s)
555             );
556           },
557           none: void 0,
558         });
559       } else if (op == UserOperation.PasswordReset) {
560         toast(i18n.t("reset_password_mail_sent"));
561       } else if (op == UserOperation.GetSite) {
562         let data = wsJsonToRes<GetSiteResponse>(msg, GetSiteResponse);
563         this.setState({ siteRes: data });
564       }
565     }
566   }
567 }