]> Untitled Git - lemmy-ui.git/blob - src/shared/components/home/login.tsx
Password strength meter (#427)
[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             />
221           </div>
222         </div>
223
224         <div class="form-group row">
225           <label class="col-sm-2 col-form-label" htmlFor="register-email">
226             {i18n.t("email")}
227           </label>
228           <div class="col-sm-10">
229             <input
230               type="email"
231               id="register-email"
232               class="form-control"
233               placeholder={i18n.t("optional")}
234               value={this.state.registerForm.email}
235               autoComplete="email"
236               onInput={linkEvent(this, this.handleRegisterEmailChange)}
237               minLength={3}
238             />
239             {!validEmail(this.state.registerForm.email) && (
240               <div class="mt-2 mb-0 alert alert-light" role="alert">
241                 <Icon icon="alert-triangle" classes="icon-inline mr-2" />
242                 {i18n.t("no_password_reset")}
243               </div>
244             )}
245           </div>
246         </div>
247
248         <div class="form-group row">
249           <label class="col-sm-2 col-form-label" htmlFor="register-password">
250             {i18n.t("password")}
251           </label>
252           <div class="col-sm-10">
253             <input
254               type="password"
255               id="register-password"
256               value={this.state.registerForm.password}
257               autoComplete="new-password"
258               onInput={linkEvent(this, this.handleRegisterPasswordChange)}
259               minLength={10}
260               maxLength={60}
261               class="form-control"
262               required
263             />
264             {this.state.registerForm.password && (
265               <div class={this.passwordColorClass}>
266                 {i18n.t(this.passwordStrength as I18nKeys)}
267               </div>
268             )}
269           </div>
270         </div>
271
272         <div class="form-group row">
273           <label
274             class="col-sm-2 col-form-label"
275             htmlFor="register-verify-password"
276           >
277             {i18n.t("verify_password")}
278           </label>
279           <div class="col-sm-10">
280             <input
281               type="password"
282               id="register-verify-password"
283               value={this.state.registerForm.password_verify}
284               autoComplete="new-password"
285               onInput={linkEvent(this, this.handleRegisterPasswordVerifyChange)}
286               maxLength={60}
287               class="form-control"
288               required
289             />
290           </div>
291         </div>
292
293         {this.state.captcha && (
294           <div class="form-group row">
295             <label class="col-sm-2" htmlFor="register-captcha">
296               <span class="mr-2">{i18n.t("enter_code")}</span>
297               <button
298                 type="button"
299                 class="btn btn-secondary"
300                 onClick={linkEvent(this, this.handleRegenCaptcha)}
301                 aria-label={i18n.t("captcha")}
302               >
303                 <Icon icon="refresh-cw" classes="icon-refresh-cw" />
304               </button>
305             </label>
306             {this.showCaptcha()}
307             <div class="col-sm-6">
308               <input
309                 type="text"
310                 class="form-control"
311                 id="register-captcha"
312                 value={this.state.registerForm.captcha_answer}
313                 onInput={linkEvent(
314                   this,
315                   this.handleRegisterCaptchaAnswerChange
316                 )}
317                 required
318               />
319             </div>
320           </div>
321         )}
322         {this.state.site_view.site.enable_nsfw && (
323           <div class="form-group row">
324             <div class="col-sm-10">
325               <div class="form-check">
326                 <input
327                   class="form-check-input"
328                   id="register-show-nsfw"
329                   type="checkbox"
330                   checked={this.state.registerForm.show_nsfw}
331                   onChange={linkEvent(this, this.handleRegisterShowNsfwChange)}
332                 />
333                 <label class="form-check-label" htmlFor="register-show-nsfw">
334                   {i18n.t("show_nsfw")}
335                 </label>
336               </div>
337             </div>
338           </div>
339         )}
340         {this.isLemmyMl && (
341           <div class="mt-2 mb-0 alert alert-light" role="alert">
342             <T i18nKey="lemmy_ml_registration_message">
343               #<a href={joinLemmyUrl}>#</a>
344             </T>
345           </div>
346         )}
347         <div class="form-group row">
348           <div class="col-sm-10">
349             <button type="submit" class="btn btn-secondary">
350               {this.state.registerLoading ? <Spinner /> : i18n.t("sign_up")}
351             </button>
352           </div>
353         </div>
354       </form>
355     );
356   }
357
358   showCaptcha() {
359     return (
360       <div class="col-sm-4">
361         {this.state.captcha.ok && (
362           <>
363             <img
364               class="rounded-top img-fluid"
365               src={this.captchaPngSrc()}
366               style="border-bottom-right-radius: 0; border-bottom-left-radius: 0;"
367               alt={i18n.t("captcha")}
368             />
369             {this.state.captcha.ok.wav && (
370               <button
371                 class="rounded-bottom btn btn-sm btn-secondary btn-block"
372                 style="border-top-right-radius: 0; border-top-left-radius: 0;"
373                 title={i18n.t("play_captcha_audio")}
374                 onClick={linkEvent(this, this.handleCaptchaPlay)}
375                 type="button"
376                 disabled={this.state.captchaPlaying}
377               >
378                 <Icon icon="play" classes="icon-play" />
379               </button>
380             )}
381           </>
382         )}
383       </div>
384     );
385   }
386
387   get passwordStrength() {
388     return passwordStrength(
389       this.state.registerForm.password,
390       passwordStrengthOptions
391     ).value;
392   }
393
394   get passwordColorClass(): string {
395     let strength = this.passwordStrength;
396
397     if (["weak", "medium"].includes(strength)) {
398       return "text-warning";
399     } else if (strength == "strong") {
400       return "text-success";
401     } else {
402       return "text-danger";
403     }
404   }
405
406   handleLoginSubmit(i: Login, event: any) {
407     event.preventDefault();
408     i.state.loginLoading = true;
409     i.setState(i.state);
410     WebSocketService.Instance.send(wsClient.login(i.state.loginForm));
411   }
412
413   handleLoginUsernameChange(i: Login, event: any) {
414     i.state.loginForm.username_or_email = event.target.value;
415     i.setState(i.state);
416   }
417
418   handleLoginPasswordChange(i: Login, event: any) {
419     i.state.loginForm.password = event.target.value;
420     i.setState(i.state);
421   }
422
423   handleRegisterSubmit(i: Login, event: any) {
424     event.preventDefault();
425     i.state.registerLoading = true;
426     i.setState(i.state);
427     WebSocketService.Instance.send(wsClient.register(i.state.registerForm));
428   }
429
430   handleRegisterUsernameChange(i: Login, event: any) {
431     i.state.registerForm.username = event.target.value;
432     i.setState(i.state);
433   }
434
435   handleRegisterEmailChange(i: Login, event: any) {
436     i.state.registerForm.email = event.target.value;
437     if (i.state.registerForm.email == "") {
438       i.state.registerForm.email = undefined;
439     }
440     i.setState(i.state);
441   }
442
443   handleRegisterPasswordChange(i: Login, event: any) {
444     i.state.registerForm.password = event.target.value;
445     i.setState(i.state);
446   }
447
448   handleRegisterPasswordVerifyChange(i: Login, event: any) {
449     i.state.registerForm.password_verify = event.target.value;
450     i.setState(i.state);
451   }
452
453   handleRegisterShowNsfwChange(i: Login, event: any) {
454     i.state.registerForm.show_nsfw = event.target.checked;
455     i.setState(i.state);
456   }
457
458   handleRegisterCaptchaAnswerChange(i: Login, event: any) {
459     i.state.registerForm.captcha_answer = event.target.value;
460     i.setState(i.state);
461   }
462
463   handleRegenCaptcha(i: Login) {
464     i.audio = null;
465     i.state.captchaPlaying = false;
466     i.setState(i.state);
467     WebSocketService.Instance.send(wsClient.getCaptcha());
468   }
469
470   handlePasswordReset(i: Login, event: any) {
471     event.preventDefault();
472     let resetForm: PasswordReset = {
473       email: i.state.loginForm.username_or_email,
474     };
475     WebSocketService.Instance.send(wsClient.passwordReset(resetForm));
476   }
477
478   handleCaptchaPlay(i: Login) {
479     // This was a bad bug, it should only build the new audio on a new file.
480     // Replays would stop prematurely if this was rebuilt every time.
481     if (i.audio == null) {
482       let base64 = `data:audio/wav;base64,${i.state.captcha.ok.wav}`;
483       i.audio = new Audio(base64);
484     }
485
486     i.audio.play();
487
488     i.state.captchaPlaying = true;
489     i.setState(i.state);
490
491     i.audio.addEventListener("ended", () => {
492       i.audio.currentTime = 0;
493       i.state.captchaPlaying = false;
494       i.setState(i.state);
495     });
496   }
497
498   captchaPngSrc() {
499     return `data:image/png;base64,${this.state.captcha.ok.png}`;
500   }
501
502   parseMessage(msg: any) {
503     let op = wsUserOp(msg);
504     console.log(msg);
505     if (msg.error) {
506       toast(i18n.t(msg.error), "danger");
507       this.state = this.emptyState;
508       this.state.registerForm.captcha_answer = undefined;
509       // Refetch another captcha
510       WebSocketService.Instance.send(wsClient.getCaptcha());
511       this.setState(this.state);
512       return;
513     } else {
514       if (op == UserOperation.Login) {
515         let data = wsJsonToRes<LoginResponse>(msg).data;
516         this.state = this.emptyState;
517         this.setState(this.state);
518         UserService.Instance.login(data);
519         WebSocketService.Instance.send(
520           wsClient.userJoin({
521             auth: authField(),
522           })
523         );
524         toast(i18n.t("logged_in"));
525         this.props.history.push("/");
526       } else if (op == UserOperation.Register) {
527         let data = wsJsonToRes<LoginResponse>(msg).data;
528         this.state = this.emptyState;
529         this.setState(this.state);
530         UserService.Instance.login(data);
531         WebSocketService.Instance.send(
532           wsClient.userJoin({
533             auth: authField(),
534           })
535         );
536         this.props.history.push("/communities");
537       } else if (op == UserOperation.GetCaptcha) {
538         let data = wsJsonToRes<GetCaptchaResponse>(msg).data;
539         if (data.ok) {
540           this.state.captcha = data;
541           this.state.registerForm.captcha_uuid = data.ok.uuid;
542           this.setState(this.state);
543         }
544       } else if (op == UserOperation.PasswordReset) {
545         toast(i18n.t("reset_password_mail_sent"));
546       } else if (op == UserOperation.GetSite) {
547         let data = wsJsonToRes<GetSiteResponse>(msg).data;
548         this.state.site_view = data.site_view;
549         this.setState(this.state);
550       }
551     }
552   }
553 }