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