]> Untitled Git - lemmy-ui.git/blob - src/shared/components/home/signup.tsx
Merge branch 'main' into expand-video-embeds-to-fullwidth
[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 { i18n } from "../../i18next";
17 import { mdToHtml } from "../../markdown";
18 import { UserService } from "../../services";
19 import { HttpService, RequestState } from "../../services/HttpService";
20 import { toast } from "../../toast";
21 import { HtmlTags } from "../common/html-tags";
22 import { Icon, Spinner } from "../common/icon";
23 import { MarkdownTextArea } from "../common/markdown-textarea";
24
25 const passwordStrengthOptions: Options<string> = [
26   {
27     id: 0,
28     value: "very_weak",
29     minDiversity: 0,
30     minLength: 0,
31   },
32   {
33     id: 1,
34     value: "weak",
35     minDiversity: 2,
36     minLength: 10,
37   },
38   {
39     id: 2,
40     value: "medium",
41     minDiversity: 3,
42     minLength: 12,
43   },
44   {
45     id: 3,
46     value: "strong",
47     minDiversity: 4,
48     minLength: 14,
49   },
50 ];
51
52 interface State {
53   registerRes: RequestState<LoginResponse>;
54   captchaRes: RequestState<GetCaptchaResponse>;
55   form: {
56     username?: string;
57     email?: string;
58     password?: string;
59     password_verify?: string;
60     show_nsfw: boolean;
61     captcha_uuid?: string;
62     captcha_answer?: string;
63     honeypot?: string;
64     answer?: string;
65   };
66   captchaPlaying: boolean;
67   siteRes: GetSiteResponse;
68 }
69
70 export class Signup extends Component<any, State> {
71   private isoData = setIsoData(this.context);
72   private audio?: HTMLAudioElement;
73
74   state: State = {
75     registerRes: { state: "empty" },
76     captchaRes: { state: "empty" },
77     form: {
78       show_nsfw: false,
79     },
80     captchaPlaying: false,
81     siteRes: this.isoData.site_res,
82   };
83
84   constructor(props: any, context: any) {
85     super(props, context);
86
87     this.handleAnswerChange = this.handleAnswerChange.bind(this);
88   }
89
90   async componentDidMount() {
91     if (this.state.siteRes.site_view.local_site.captcha_enabled) {
92       await this.fetchCaptcha();
93     }
94   }
95
96   async fetchCaptcha() {
97     this.setState({ captchaRes: { state: "loading" } });
98     this.setState({
99       captchaRes: await HttpService.client.getCaptcha({}),
100     });
101
102     this.setState(s => {
103       if (s.captchaRes.state == "success") {
104         s.form.captcha_uuid = s.captchaRes.data.ok?.uuid;
105       }
106       return s;
107     });
108   }
109
110   get documentTitle(): string {
111     const siteView = this.state.siteRes.site_view;
112     return `${this.titleName(siteView)} - ${siteView.site.name}`;
113   }
114
115   titleName(siteView: SiteView): string {
116     return i18n.t(
117       siteView.local_site.private_instance ? "apply_to_join" : "sign_up"
118     );
119   }
120
121   get isLemmyMl(): boolean {
122     return isBrowser() && window.location.hostname == "lemmy.ml";
123   }
124
125   render() {
126     return (
127       <div className="home-signup container-lg">
128         <HtmlTags
129           title={this.documentTitle}
130           path={this.context.router.route.match.url}
131         />
132         <div className="row">
133           <div className="col-12 col-lg-6 offset-lg-3">
134             {this.registerForm()}
135           </div>
136         </div>
137       </div>
138     );
139   }
140
141   registerForm() {
142     const siteView = this.state.siteRes.site_view;
143     return (
144       <form onSubmit={linkEvent(this, this.handleRegisterSubmit)}>
145         <h5>{this.titleName(siteView)}</h5>
146
147         {this.isLemmyMl && (
148           <div className="mb-3 row">
149             <div className="mt-2 mb-0 alert alert-warning" role="alert">
150               <T i18nKey="lemmy_ml_registration_message">
151                 #<a href={joinLemmyUrl}>#</a>
152               </T>
153             </div>
154           </div>
155         )}
156
157         <div className="mb-3 row">
158           <label
159             className="col-sm-2 col-form-label"
160             htmlFor="register-username"
161           >
162             {i18n.t("username")}
163           </label>
164
165           <div className="col-sm-10">
166             <input
167               type="text"
168               id="register-username"
169               className="form-control"
170               value={this.state.form.username}
171               onInput={linkEvent(this, this.handleRegisterUsernameChange)}
172               required
173               minLength={3}
174               pattern="[a-zA-Z0-9_]+"
175               title={i18n.t("community_reqs")}
176             />
177           </div>
178         </div>
179
180         <div className="mb-3 row">
181           <label className="col-sm-2 col-form-label" htmlFor="register-email">
182             {i18n.t("email")}
183           </label>
184           <div className="col-sm-10">
185             <input
186               type="email"
187               id="register-email"
188               className="form-control"
189               placeholder={
190                 siteView.local_site.require_email_verification
191                   ? i18n.t("required")
192                   : i18n.t("optional")
193               }
194               value={this.state.form.email}
195               autoComplete="email"
196               onInput={linkEvent(this, this.handleRegisterEmailChange)}
197               required={siteView.local_site.require_email_verification}
198               minLength={3}
199             />
200             {!siteView.local_site.require_email_verification &&
201               this.state.form.email &&
202               !validEmail(this.state.form.email) && (
203                 <div className="mt-2 mb-0 alert alert-warning" role="alert">
204                   <Icon icon="alert-triangle" classes="icon-inline me-2" />
205                   {i18n.t("no_password_reset")}
206                 </div>
207               )}
208           </div>
209         </div>
210
211         <div className="mb-3 row">
212           <label
213             className="col-sm-2 col-form-label"
214             htmlFor="register-password"
215           >
216             {i18n.t("password")}
217           </label>
218           <div className="col-sm-10">
219             <input
220               type="password"
221               id="register-password"
222               value={this.state.form.password}
223               autoComplete="new-password"
224               onInput={linkEvent(this, this.handleRegisterPasswordChange)}
225               minLength={10}
226               maxLength={60}
227               className="form-control"
228               required
229             />
230             {this.state.form.password && (
231               <div className={this.passwordColorClass}>
232                 {i18n.t(this.passwordStrength as NoOptionI18nKeys)}
233               </div>
234             )}
235           </div>
236         </div>
237
238         <div className="mb-3 row">
239           <label
240             className="col-sm-2 col-form-label"
241             htmlFor="register-verify-password"
242           >
243             {i18n.t("verify_password")}
244           </label>
245           <div className="col-sm-10">
246             <input
247               type="password"
248               id="register-verify-password"
249               value={this.state.form.password_verify}
250               autoComplete="new-password"
251               onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
252               maxLength={60}
253               className="form-control"
254               required
255             />
256           </div>
257         </div>
258
259         {siteView.local_site.registration_mode == "RequireApplication" && (
260           <>
261             <div className="mb-3 row">
262               <div className="offset-sm-2 col-sm-10">
263                 <div className="mt-2 alert alert-warning" role="alert">
264                   <Icon icon="alert-triangle" classes="icon-inline me-2" />
265                   {i18n.t("fill_out_application")}
266                 </div>
267                 {siteView.local_site.application_question && (
268                   <div
269                     className="md-div"
270                     dangerouslySetInnerHTML={mdToHtml(
271                       siteView.local_site.application_question
272                     )}
273                   />
274                 )}
275               </div>
276             </div>
277
278             <div className="mb-3 row">
279               <label
280                 className="col-sm-2 col-form-label"
281                 htmlFor="application_answer"
282               >
283                 {i18n.t("answer")}
284               </label>
285               <div className="col-sm-10">
286                 <MarkdownTextArea
287                   initialContent=""
288                   onContentChange={this.handleAnswerChange}
289                   hideNavigationWarnings
290                   allLanguages={[]}
291                   siteLanguages={[]}
292                 />
293               </div>
294             </div>
295           </>
296         )}
297         {this.renderCaptcha()}
298         <div className="mb-3 row">
299           <div className="col-sm-10">
300             <div className="form-check">
301               <input
302                 className="form-check-input"
303                 id="register-show-nsfw"
304                 type="checkbox"
305                 checked={this.state.form.show_nsfw}
306                 onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
307               />
308               <label className="form-check-label" htmlFor="register-show-nsfw">
309                 {i18n.t("show_nsfw")}
310               </label>
311             </div>
312           </div>
313         </div>
314         <input
315           tabIndex={-1}
316           autoComplete="false"
317           name="a_password"
318           type="text"
319           className="form-control honeypot"
320           id="register-honey"
321           value={this.state.form.honeypot}
322           onInput={linkEvent(this, this.handleHoneyPotChange)}
323         />
324         <div className="mb-3 row">
325           <div className="col-sm-10">
326             <button type="submit" className="btn btn-secondary">
327               {this.state.registerRes.state == "loading" ? (
328                 <Spinner />
329               ) : (
330                 this.titleName(siteView)
331               )}
332             </button>
333           </div>
334         </div>
335       </form>
336     );
337   }
338
339   renderCaptcha() {
340     switch (this.state.captchaRes.state) {
341       case "loading":
342         return <Spinner />;
343       case "success": {
344         const res = this.state.captchaRes.data;
345         return (
346           <div className="mb-3 row">
347             <label className="col-sm-2" htmlFor="register-captcha">
348               <span className="me-2">{i18n.t("enter_code")}</span>
349               <button
350                 type="button"
351                 className="btn btn-secondary"
352                 onClick={linkEvent(this, this.handleRegenCaptcha)}
353                 aria-label={i18n.t("captcha")}
354               >
355                 <Icon icon="refresh-cw" classes="icon-refresh-cw" />
356               </button>
357             </label>
358             {this.showCaptcha(res)}
359             <div className="col-sm-6">
360               <input
361                 type="text"
362                 className="form-control"
363                 id="register-captcha"
364                 value={this.state.form.captcha_answer}
365                 onInput={linkEvent(
366                   this,
367                   this.handleRegisterCaptchaAnswerChange
368                 )}
369                 required
370               />
371             </div>
372           </div>
373         );
374       }
375     }
376   }
377
378   showCaptcha(res: GetCaptchaResponse) {
379     const captchaRes = res?.ok;
380     return captchaRes ? (
381       <div className="col-sm-4">
382         <>
383           <img
384             className="rounded-top img-fluid"
385             src={this.captchaPngSrc(captchaRes)}
386             style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
387             alt={i18n.t("captcha")}
388           />
389           {captchaRes.wav && (
390             <button
391               className="rounded-bottom btn btn-sm btn-secondary d-block"
392               style="border-top-right-radius: 0; border-top-left-radius: 0;"
393               title={i18n.t("play_captcha_audio")}
394               onClick={linkEvent(this, this.handleCaptchaPlay)}
395               type="button"
396               disabled={this.state.captchaPlaying}
397             >
398               <Icon icon="play" classes="icon-play" />
399             </button>
400           )}
401         </>
402       </div>
403     ) : (
404       <></>
405     );
406   }
407
408   get passwordStrength(): string | undefined {
409     const password = this.state.form.password;
410     return password
411       ? passwordStrength(password, passwordStrengthOptions).value
412       : undefined;
413   }
414
415   get passwordColorClass(): string {
416     const strength = this.passwordStrength;
417
418     if (strength && ["weak", "medium"].includes(strength)) {
419       return "text-warning";
420     } else if (strength == "strong") {
421       return "text-success";
422     } else {
423       return "text-danger";
424     }
425   }
426
427   async handleRegisterSubmit(i: Signup, event: any) {
428     event.preventDefault();
429     const {
430       show_nsfw,
431       answer,
432       captcha_answer,
433       captcha_uuid,
434       email,
435       honeypot,
436       password,
437       password_verify,
438       username,
439     } = i.state.form;
440     if (username && password && password_verify) {
441       i.setState({ registerRes: { state: "loading" } });
442
443       const registerRes = await HttpService.client.register({
444         username,
445         password,
446         password_verify,
447         email,
448         show_nsfw,
449         captcha_uuid,
450         captcha_answer,
451         honeypot,
452         answer,
453       });
454       switch (registerRes.state) {
455         case "failed": {
456           toast(registerRes.msg, "danger");
457           i.setState({ registerRes: { state: "empty" } });
458           break;
459         }
460
461         case "success": {
462           const data = registerRes.data;
463
464           // Only log them in if a jwt was set
465           if (data.jwt) {
466             UserService.Instance.login(data);
467
468             const site = await HttpService.client.getSite({ auth: myAuth() });
469
470             if (site.state === "success") {
471               UserService.Instance.myUserInfo = site.data.my_user;
472             }
473
474             i.props.history.replace("/communities");
475           } else {
476             if (data.verify_email_sent) {
477               toast(i18n.t("verify_email_sent"));
478             }
479             if (data.registration_created) {
480               toast(i18n.t("registration_application_sent"));
481             }
482             i.props.history.push("/");
483           }
484           break;
485         }
486       }
487     }
488   }
489
490   handleRegisterUsernameChange(i: Signup, event: any) {
491     i.state.form.username = event.target.value.trim();
492     i.setState(i.state);
493   }
494
495   handleRegisterEmailChange(i: Signup, event: any) {
496     i.state.form.email = event.target.value;
497     if (i.state.form.email == "") {
498       i.state.form.email = undefined;
499     }
500     i.setState(i.state);
501   }
502
503   handleRegisterPasswordChange(i: Signup, event: any) {
504     i.state.form.password = event.target.value;
505     i.setState(i.state);
506   }
507
508   handleRegisterPasswordVerifyChange(i: Signup, event: any) {
509     i.state.form.password_verify = event.target.value;
510     i.setState(i.state);
511   }
512
513   handleRegisterShowNsfwChange(i: Signup, event: any) {
514     i.state.form.show_nsfw = event.target.checked;
515     i.setState(i.state);
516   }
517
518   handleRegisterCaptchaAnswerChange(i: Signup, event: any) {
519     i.state.form.captcha_answer = event.target.value;
520     i.setState(i.state);
521   }
522
523   handleAnswerChange(val: string) {
524     this.setState(s => ((s.form.answer = val), s));
525   }
526
527   handleHoneyPotChange(i: Signup, event: any) {
528     i.state.form.honeypot = event.target.value;
529     i.setState(i.state);
530   }
531
532   async handleRegenCaptcha(i: Signup) {
533     i.audio = undefined;
534     i.setState({ captchaPlaying: false });
535     await i.fetchCaptcha();
536   }
537
538   handleCaptchaPlay(i: Signup) {
539     // This was a bad bug, it should only build the new audio on a new file.
540     // Replays would stop prematurely if this was rebuilt every time.
541
542     if (i.state.captchaRes.state == "success" && i.state.captchaRes.data.ok) {
543       const captchaRes = i.state.captchaRes.data.ok;
544       if (!i.audio) {
545         const base64 = `data:audio/wav;base64,${captchaRes.wav}`;
546         i.audio = new Audio(base64);
547         i.audio.play();
548
549         i.setState({ captchaPlaying: true });
550
551         i.audio.addEventListener("ended", () => {
552           if (i.audio) {
553             i.audio.currentTime = 0;
554             i.setState({ captchaPlaying: false });
555           }
556         });
557       }
558     }
559   }
560
561   captchaPngSrc(captcha: CaptchaResponse) {
562     return `data:image/png;base64,${captcha.png}`;
563   }
564 }