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