]> Untitled Git - lemmy-ui.git/blob - src/shared/components/home/signup.tsx
Merge branch 'main' into feat/default-to-user-primary-lang
[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 onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
144         <h5>{this.titleName(siteView)}</h5>
145
146         {this.isLemmyMl && (
147           <div className="mb-3 row">
148             <div className="mt-2 mb-0 alert alert-warning" role="alert">
149               <T i18nKey="lemmy_ml_registration_message">
150                 #<a href={joinLemmyUrl}>#</a>
151               </T>
152             </div>
153           </div>
154         )}
155
156         <div className="mb-3 row">
157           <label
158             className="col-sm-2 col-form-label"
159             htmlFor="register-username"
160           >
161             {I18NextService.i18n.t("username")}
162           </label>
163
164           <div className="col-sm-10">
165             <input
166               type="text"
167               id="register-username"
168               className="form-control"
169               value={this.state.form.username}
170               onInput={linkEvent(this, this.handleRegisterUsernameChange)}
171               required
172               minLength={3}
173               pattern="[a-zA-Z0-9_]+"
174               title={I18NextService.i18n.t("community_reqs")}
175             />
176           </div>
177         </div>
178
179         <div className="mb-3 row">
180           <label className="col-sm-2 col-form-label" htmlFor="register-email">
181             {I18NextService.i18n.t("email")}
182           </label>
183           <div className="col-sm-10">
184             <input
185               type="email"
186               id="register-email"
187               className="form-control"
188               placeholder={
189                 siteView.local_site.require_email_verification
190                   ? I18NextService.i18n.t("required")
191                   : I18NextService.i18n.t("optional")
192               }
193               value={this.state.form.email}
194               autoComplete="email"
195               onInput={linkEvent(this, this.handleRegisterEmailChange)}
196               required={siteView.local_site.require_email_verification}
197               minLength={3}
198             />
199             {!siteView.local_site.require_email_verification &&
200               this.state.form.email &&
201               !validEmail(this.state.form.email) && (
202                 <div className="mt-2 mb-0 alert alert-warning" role="alert">
203                   <Icon icon="alert-triangle" classes="icon-inline me-2" />
204                   {I18NextService.i18n.t("no_password_reset")}
205                 </div>
206               )}
207           </div>
208         </div>
209
210         <div className="mb-3 row">
211           <label
212             className="col-sm-2 col-form-label"
213             htmlFor="register-password"
214           >
215             {I18NextService.i18n.t("password")}
216           </label>
217           <div className="col-sm-10">
218             <input
219               type="password"
220               id="register-password"
221               value={this.state.form.password}
222               autoComplete="new-password"
223               onInput={linkEvent(this, this.handleRegisterPasswordChange)}
224               minLength={10}
225               maxLength={60}
226               className="form-control"
227               required
228             />
229             {this.state.form.password && (
230               <div className={this.passwordColorClass}>
231                 {I18NextService.i18n.t(
232                   this.passwordStrength as NoOptionI18nKeys
233                 )}
234               </div>
235             )}
236           </div>
237         </div>
238
239         <div className="mb-3 row">
240           <label
241             className="col-sm-2 col-form-label"
242             htmlFor="register-verify-password"
243           >
244             {I18NextService.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="mb-3 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 me-2" />
266                   {I18NextService.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="mb-3 row">
280               <label
281                 className="col-sm-2 col-form-label"
282                 htmlFor="application_answer"
283               >
284                 {I18NextService.i18n.t("answer")}
285               </label>
286               <div className="col-sm-10">
287                 <MarkdownTextArea
288                   initialContent=""
289                   onContentChange={this.handleAnswerChange}
290                   hideNavigationWarnings
291                   allLanguages={[]}
292                   siteLanguages={[]}
293                 />
294               </div>
295             </div>
296           </>
297         )}
298         {this.renderCaptcha()}
299         <div className="mb-3 row">
300           <div className="col-sm-10">
301             <div className="form-check">
302               <input
303                 className="form-check-input"
304                 id="register-show-nsfw"
305                 type="checkbox"
306                 checked={this.state.form.show_nsfw}
307                 onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
308               />
309               <label className="form-check-label" htmlFor="register-show-nsfw">
310                 {I18NextService.i18n.t("show_nsfw")}
311               </label>
312             </div>
313           </div>
314         </div>
315         <input
316           tabIndex={-1}
317           autoComplete="false"
318           name="a_password"
319           type="text"
320           className="form-control honeypot"
321           id="register-honey"
322           value={this.state.form.honeypot}
323           onInput={linkEvent(this, this.handleHoneyPotChange)}
324         />
325         <div className="mb-3 row">
326           <div className="col-sm-10">
327             <button type="submit" className="btn btn-secondary">
328               {this.state.registerRes.state == "loading" ? (
329                 <Spinner />
330               ) : (
331                 this.titleName(siteView)
332               )}
333             </button>
334           </div>
335         </div>
336       </form>
337     );
338   }
339
340   renderCaptcha() {
341     switch (this.state.captchaRes.state) {
342       case "loading":
343         return <Spinner />;
344       case "success": {
345         const res = this.state.captchaRes.data;
346         return (
347           <div className="mb-3 row">
348             <label className="col-sm-2" htmlFor="register-captcha">
349               <span className="me-2">
350                 {I18NextService.i18n.t("enter_code")}
351               </span>
352               <button
353                 type="button"
354                 className="btn btn-secondary"
355                 onClick={linkEvent(this, this.handleRegenCaptcha)}
356                 aria-label={I18NextService.i18n.t("captcha")}
357               >
358                 <Icon icon="refresh-cw" classes="icon-refresh-cw" />
359               </button>
360             </label>
361             {this.showCaptcha(res)}
362             <div className="col-sm-6">
363               <input
364                 type="text"
365                 className="form-control"
366                 id="register-captcha"
367                 value={this.state.form.captcha_answer}
368                 onInput={linkEvent(
369                   this,
370                   this.handleRegisterCaptchaAnswerChange
371                 )}
372                 required
373               />
374             </div>
375           </div>
376         );
377       }
378     }
379   }
380
381   showCaptcha(res: GetCaptchaResponse) {
382     const captchaRes = res?.ok;
383     return captchaRes ? (
384       <div className="col-sm-4">
385         <>
386           <img
387             className="rounded-top img-fluid"
388             src={this.captchaPngSrc(captchaRes)}
389             style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
390             alt={I18NextService.i18n.t("captcha")}
391           />
392           {captchaRes.wav && (
393             <button
394               className="rounded-bottom btn btn-sm btn-secondary d-block"
395               style="border-top-right-radius: 0; border-top-left-radius: 0;"
396               title={I18NextService.i18n.t("play_captcha_audio")}
397               onClick={linkEvent(this, this.handleCaptchaPlay)}
398               type="button"
399               disabled={this.state.captchaPlaying}
400             >
401               <Icon icon="play" classes="icon-play" />
402             </button>
403           )}
404         </>
405       </div>
406     ) : (
407       <></>
408     );
409   }
410
411   get passwordStrength(): string | undefined {
412     const password = this.state.form.password;
413     return password
414       ? passwordStrength(password, passwordStrengthOptions).value
415       : undefined;
416   }
417
418   get passwordColorClass(): string {
419     const strength = this.passwordStrength;
420
421     if (strength && ["weak", "medium"].includes(strength)) {
422       return "text-warning";
423     } else if (strength == "strong") {
424       return "text-success";
425     } else {
426       return "text-danger";
427     }
428   }
429
430   async handleRegisterSubmit(i: Signup, event: any) {
431     event.preventDefault();
432     const {
433       show_nsfw,
434       answer,
435       captcha_answer,
436       captcha_uuid,
437       email,
438       honeypot,
439       password,
440       password_verify,
441       username,
442     } = i.state.form;
443     if (username && password && password_verify) {
444       i.setState({ registerRes: { state: "loading" } });
445
446       const registerRes = await HttpService.client.register({
447         username,
448         password,
449         password_verify,
450         email,
451         show_nsfw,
452         captcha_uuid,
453         captcha_answer,
454         honeypot,
455         answer,
456       });
457       switch (registerRes.state) {
458         case "failed": {
459           toast(registerRes.msg, "danger");
460           i.setState({ registerRes: { state: "empty" } });
461           break;
462         }
463
464         case "success": {
465           const data = registerRes.data;
466
467           // Only log them in if a jwt was set
468           if (data.jwt) {
469             UserService.Instance.login(data);
470
471             const site = await HttpService.client.getSite({ auth: myAuth() });
472
473             if (site.state === "success") {
474               UserService.Instance.myUserInfo = site.data.my_user;
475             }
476
477             i.props.history.replace("/communities");
478           } else {
479             if (data.verify_email_sent) {
480               toast(I18NextService.i18n.t("verify_email_sent"));
481             }
482             if (data.registration_created) {
483               toast(I18NextService.i18n.t("registration_application_sent"));
484             }
485             i.props.history.push("/");
486           }
487           break;
488         }
489       }
490     }
491   }
492
493   handleRegisterUsernameChange(i: Signup, event: any) {
494     i.state.form.username = event.target.value.trim();
495     i.setState(i.state);
496   }
497
498   handleRegisterEmailChange(i: Signup, event: any) {
499     i.state.form.email = event.target.value;
500     if (i.state.form.email == "") {
501       i.state.form.email = undefined;
502     }
503     i.setState(i.state);
504   }
505
506   handleRegisterPasswordChange(i: Signup, event: any) {
507     i.state.form.password = event.target.value;
508     i.setState(i.state);
509   }
510
511   handleRegisterPasswordVerifyChange(i: Signup, event: any) {
512     i.state.form.password_verify = event.target.value;
513     i.setState(i.state);
514   }
515
516   handleRegisterShowNsfwChange(i: Signup, event: any) {
517     i.state.form.show_nsfw = event.target.checked;
518     i.setState(i.state);
519   }
520
521   handleRegisterCaptchaAnswerChange(i: Signup, event: any) {
522     i.state.form.captcha_answer = event.target.value;
523     i.setState(i.state);
524   }
525
526   handleAnswerChange(val: string) {
527     this.setState(s => ((s.form.answer = val), s));
528   }
529
530   handleHoneyPotChange(i: Signup, event: any) {
531     i.state.form.honeypot = event.target.value;
532     i.setState(i.state);
533   }
534
535   async handleRegenCaptcha(i: Signup) {
536     i.audio = undefined;
537     i.setState({ captchaPlaying: false });
538     await i.fetchCaptcha();
539   }
540
541   handleCaptchaPlay(i: Signup) {
542     // This was a bad bug, it should only build the new audio on a new file.
543     // Replays would stop prematurely if this was rebuilt every time.
544
545     if (i.state.captchaRes.state == "success" && i.state.captchaRes.data.ok) {
546       const captchaRes = i.state.captchaRes.data.ok;
547       if (!i.audio) {
548         const base64 = `data:audio/wav;base64,${captchaRes.wav}`;
549         i.audio = new Audio(base64);
550         i.audio.play();
551
552         i.setState({ captchaPlaying: true });
553
554         i.audio.addEventListener("ended", () => {
555           if (i.audio) {
556             i.audio.currentTime = 0;
557             i.setState({ captchaPlaying: false });
558           }
559         });
560       }
561     }
562   }
563
564   captchaPngSrc(captcha: CaptchaResponse) {
565     return `data:image/png;base64,${captcha.png}`;
566   }
567 }