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