]> 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 { FirstLoadService, 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     FirstLoadService.isFirstLoad;
89   }
90
91   async componentDidMount() {
92     if (this.state.siteRes.site_view.local_site.captcha_enabled) {
93       await this.fetchCaptcha();
94     }
95   }
96
97   async fetchCaptcha() {
98     this.setState({ captchaRes: { state: "loading" } });
99     this.setState({
100       captchaRes: await HttpService.client.getCaptcha({}),
101     });
102
103     this.setState(s => {
104       if (s.captchaRes.state == "success") {
105         s.form.captcha_uuid = s.captchaRes.data.ok?.uuid;
106       }
107       return s;
108     });
109   }
110
111   get documentTitle(): string {
112     const siteView = this.state.siteRes.site_view;
113     return `${this.titleName(siteView)} - ${siteView.site.name}`;
114   }
115
116   titleName(siteView: SiteView): string {
117     return I18NextService.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="home-signup 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     const 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="mb-3 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="mb-3 row">
159           <label
160             className="col-sm-2 col-form-label"
161             htmlFor="register-username"
162           >
163             {I18NextService.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={I18NextService.i18n.t("community_reqs")}
177             />
178           </div>
179         </div>
180
181         <div className="mb-3 row">
182           <label className="col-sm-2 col-form-label" htmlFor="register-email">
183             {I18NextService.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                   ? I18NextService.i18n.t("required")
193                   : I18NextService.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 me-2" />
206                   {I18NextService.i18n.t("no_password_reset")}
207                 </div>
208               )}
209           </div>
210         </div>
211
212         <div className="mb-3 row">
213           <label
214             className="col-sm-2 col-form-label"
215             htmlFor="register-password"
216           >
217             {I18NextService.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                 {I18NextService.i18n.t(
234                   this.passwordStrength as NoOptionI18nKeys
235                 )}
236               </div>
237             )}
238           </div>
239         </div>
240
241         <div className="mb-3 row">
242           <label
243             className="col-sm-2 col-form-label"
244             htmlFor="register-verify-password"
245           >
246             {I18NextService.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="mb-3 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 me-2" />
268                   {I18NextService.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="mb-3 row">
282               <label
283                 className="col-sm-2 col-form-label"
284                 htmlFor="application_answer"
285               >
286                 {I18NextService.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         <div className="mb-3 row">
302           <div className="col-sm-10">
303             <div className="form-check">
304               <input
305                 className="form-check-input"
306                 id="register-show-nsfw"
307                 type="checkbox"
308                 checked={this.state.form.show_nsfw}
309                 onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
310               />
311               <label className="form-check-label" htmlFor="register-show-nsfw">
312                 {I18NextService.i18n.t("show_nsfw")}
313               </label>
314             </div>
315           </div>
316         </div>
317         <input
318           tabIndex={-1}
319           autoComplete="false"
320           name="a_password"
321           type="text"
322           className="form-control honeypot"
323           id="register-honey"
324           value={this.state.form.honeypot}
325           onInput={linkEvent(this, this.handleHoneyPotChange)}
326         />
327         <div className="mb-3 row">
328           <div className="col-sm-10">
329             <button type="submit" className="btn btn-secondary">
330               {this.state.registerRes.state == "loading" ? (
331                 <Spinner />
332               ) : (
333                 this.titleName(siteView)
334               )}
335             </button>
336           </div>
337         </div>
338       </form>
339     );
340   }
341
342   renderCaptcha() {
343     switch (this.state.captchaRes.state) {
344       case "loading":
345         return <Spinner />;
346       case "success": {
347         const res = this.state.captchaRes.data;
348         return (
349           <div className="mb-3 row">
350             <label className="col-sm-2" htmlFor="register-captcha">
351               <span className="me-2">
352                 {I18NextService.i18n.t("enter_code")}
353               </span>
354               <button
355                 type="button"
356                 className="btn btn-secondary"
357                 onClick={linkEvent(this, this.handleRegenCaptcha)}
358                 aria-label={I18NextService.i18n.t("captcha")}
359               >
360                 <Icon icon="refresh-cw" classes="icon-refresh-cw" />
361               </button>
362             </label>
363             {this.showCaptcha(res)}
364             <div className="col-sm-6">
365               <input
366                 type="text"
367                 className="form-control"
368                 id="register-captcha"
369                 value={this.state.form.captcha_answer}
370                 onInput={linkEvent(
371                   this,
372                   this.handleRegisterCaptchaAnswerChange
373                 )}
374                 required
375               />
376             </div>
377           </div>
378         );
379       }
380     }
381   }
382
383   showCaptcha(res: GetCaptchaResponse) {
384     const captchaRes = res?.ok;
385     return captchaRes ? (
386       <div className="col-sm-4">
387         <>
388           <img
389             className="rounded-top img-fluid"
390             src={this.captchaPngSrc(captchaRes)}
391             style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
392             alt={I18NextService.i18n.t("captcha")}
393           />
394           {captchaRes.wav && (
395             <button
396               className="rounded-bottom btn btn-sm btn-secondary d-block"
397               style="border-top-right-radius: 0; border-top-left-radius: 0;"
398               title={I18NextService.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       </div>
408     ) : (
409       <></>
410     );
411   }
412
413   get passwordStrength(): string | undefined {
414     const password = this.state.form.password;
415     return password
416       ? passwordStrength(password, passwordStrengthOptions).value
417       : undefined;
418   }
419
420   get passwordColorClass(): string {
421     const strength = this.passwordStrength;
422
423     if (strength && ["weak", "medium"].includes(strength)) {
424       return "text-warning";
425     } else if (strength == "strong") {
426       return "text-success";
427     } else {
428       return "text-danger";
429     }
430   }
431
432   async handleRegisterSubmit(i: Signup, event: any) {
433     event.preventDefault();
434     const {
435       show_nsfw,
436       answer,
437       captcha_answer,
438       captcha_uuid,
439       email,
440       honeypot,
441       password,
442       password_verify,
443       username,
444     } = i.state.form;
445     if (username && password && password_verify) {
446       i.setState({ registerRes: { state: "loading" } });
447
448       const registerRes = await HttpService.client.register({
449         username,
450         password,
451         password_verify,
452         email,
453         show_nsfw,
454         captcha_uuid,
455         captcha_answer,
456         honeypot,
457         answer,
458       });
459       switch (registerRes.state) {
460         case "failed": {
461           toast(registerRes.msg, "danger");
462           i.setState({ registerRes: { state: "empty" } });
463           break;
464         }
465
466         case "success": {
467           const data = registerRes.data;
468
469           // Only log them in if a jwt was set
470           if (data.jwt) {
471             UserService.Instance.login(data);
472
473             const site = await HttpService.client.getSite({ auth: myAuth() });
474
475             if (site.state === "success") {
476               UserService.Instance.myUserInfo = site.data.my_user;
477             }
478
479             i.props.history.replace("/communities");
480           } else {
481             if (data.verify_email_sent) {
482               toast(I18NextService.i18n.t("verify_email_sent"));
483             }
484             if (data.registration_created) {
485               toast(I18NextService.i18n.t("registration_application_sent"));
486             }
487             i.props.history.push("/");
488           }
489           break;
490         }
491       }
492     }
493   }
494
495   handleRegisterUsernameChange(i: Signup, event: any) {
496     i.state.form.username = event.target.value.trim();
497     i.setState(i.state);
498   }
499
500   handleRegisterEmailChange(i: Signup, event: any) {
501     i.state.form.email = event.target.value;
502     if (i.state.form.email == "") {
503       i.state.form.email = undefined;
504     }
505     i.setState(i.state);
506   }
507
508   handleRegisterPasswordChange(i: Signup, event: any) {
509     i.state.form.password = event.target.value;
510     i.setState(i.state);
511   }
512
513   handleRegisterPasswordVerifyChange(i: Signup, event: any) {
514     i.state.form.password_verify = event.target.value;
515     i.setState(i.state);
516   }
517
518   handleRegisterShowNsfwChange(i: Signup, event: any) {
519     i.state.form.show_nsfw = event.target.checked;
520     i.setState(i.state);
521   }
522
523   handleRegisterCaptchaAnswerChange(i: Signup, event: any) {
524     i.state.form.captcha_answer = event.target.value;
525     i.setState(i.state);
526   }
527
528   handleAnswerChange(val: string) {
529     this.setState(s => ((s.form.answer = val), s));
530   }
531
532   handleHoneyPotChange(i: Signup, event: any) {
533     i.state.form.honeypot = event.target.value;
534     i.setState(i.state);
535   }
536
537   async handleRegenCaptcha(i: Signup) {
538     i.audio = undefined;
539     i.setState({ captchaPlaying: false });
540     await i.fetchCaptcha();
541   }
542
543   handleCaptchaPlay(i: Signup) {
544     // This was a bad bug, it should only build the new audio on a new file.
545     // Replays would stop prematurely if this was rebuilt every time.
546
547     if (i.state.captchaRes.state == "success" && i.state.captchaRes.data.ok) {
548       const captchaRes = i.state.captchaRes.data.ok;
549       if (!i.audio) {
550         const base64 = `data:audio/wav;base64,${captchaRes.wav}`;
551         i.audio = new Audio(base64);
552         i.audio.play();
553
554         i.setState({ captchaPlaying: true });
555
556         i.audio.addEventListener("ended", () => {
557           if (i.audio) {
558             i.audio.currentTime = 0;
559             i.setState({ captchaPlaying: false });
560           }
561         });
562       }
563     }
564   }
565
566   captchaPngSrc(captcha: CaptchaResponse) {
567     return `data:image/png;base64,${captcha.png}`;
568   }
569 }