]> Untitled Git - lemmy-ui.git/blob - src/shared/components/home/signup.tsx
Merge branch 'main' into breakout-role-utils
[lemmy-ui.git] / src / shared / components / home / signup.tsx
1 import { isBrowser } from "@utils/browser";
2 import { Options, passwordStrength } from "check-password-strength";
3 import { NoOptionI18nKeys } 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   SiteView,
12 } from "lemmy-js-client";
13 import { i18n } from "../../i18next";
14 import { UserService } from "../../services";
15 import { HttpService, RequestState } from "../../services/HttpService";
16 import {
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="home-signup 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="mb-3 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="mb-3 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="mb-3 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 me-2" />
208                   {i18n.t("no_password_reset")}
209                 </div>
210               )}
211           </div>
212         </div>
213
214         <div className="mb-3 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="mb-3 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="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                   {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                 {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                 {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">{i18n.t("enter_code")}</span>
352               <button
353                 type="button"
354                 className="btn btn-secondary"
355                 onClick={linkEvent(this, this.handleRegenCaptcha)}
356                 aria-label={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={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={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(i18n.t("verify_email_sent"));
481             }
482             if (data.registration_created) {
483               toast(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 }