Skip to content

Commit 6b154e9

Browse files
codebykatroundhill
andauthored
Add Magic link login (#3246)
Co-authored-by: Dan Roundhill <[email protected]>
1 parent 3de1744 commit 6b154e9

File tree

7 files changed

+281
-70
lines changed

7 files changed

+281
-70
lines changed

desktop/preload.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const validChannels = [
1515
'wpLogin',
1616
];
1717

18-
contextBridge.exposeInMainWorld('electron', {
18+
const electronAPI = {
1919
confirmLogout: (changes) => {
2020
const response = remote.dialog.showMessageBoxSync({
2121
type: 'warning',
@@ -61,4 +61,10 @@ contextBridge.exposeInMainWorld('electron', {
6161
},
6262
isMac: process.platform === 'darwin',
6363
isLinux: process.platform === 'linux',
64-
});
64+
};
65+
66+
contextBridge.exposeInMainWorld('electron', electronAPI);
67+
68+
module.exports = {
69+
electronAPI: electronAPI,
70+
};

lib/auth/index.tsx

Lines changed: 164 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, { Component, Fragment } from 'react';
22
import classNames from 'classnames';
33
import cryptoRandomString from '../utils/crypto-random-string';
44
import { get } from 'lodash';
5+
import { isDev } from '../../desktop/env';
56
import MailIcon from '../icons/mail';
67
import SimplenoteLogo from '../icons/simplenote';
78
import Spinner from '../components/spinner';
@@ -19,18 +20,26 @@ type OwnProps = {
1920
hasTooManyRequests: boolean;
2021
hasUnverifiedAccount: boolean;
2122
login: (username: string, password: string) => any;
23+
loginRequested: boolean;
24+
isCompletingLogin: boolean;
25+
hasCodeError: boolean;
26+
requestLogin: (username: string) => any;
27+
completeLogin: (username: string, code: string) => any;
2228
requestSignup: (username: string) => any;
23-
tokenLogin: (username: string, token: string) => any;
2429
resetErrors: () => any;
30+
tokenLogin: (username: string, token: string) => any;
2531
};
2632

2733
type Props = OwnProps;
2834

2935
export class Auth extends Component<Props> {
3036
state = {
37+
authState: '',
3138
isCreatingAccount: false,
3239
passwordErrorMessage: null,
3340
onLine: window.navigator.onLine,
41+
usePassword: isDev, // Magic link login doesn't work in dev mode
42+
emailForPasswordForm: null,
3443
};
3544

3645
componentDidMount() {
@@ -43,6 +52,15 @@ export class Auth extends Component<Props> {
4352
window.removeEventListener('offline', this.setConnectivity, false);
4453
}
4554

55+
componentDidUpdate() {
56+
// Add the email to the username/password form if it was set
57+
if (this.state.usePassword && this.state.emailForPasswordForm) {
58+
this.usernameInput.value = this.state.emailForPasswordForm;
59+
this.setState({ emailForPasswordForm: null });
60+
this.passwordInput.focus();
61+
}
62+
}
63+
4664
setConnectivity = () => this.setState({ onLine: window.navigator.onLine });
4765

4866
render() {
@@ -51,14 +69,21 @@ export class Auth extends Component<Props> {
5169
return null;
5270
}
5371

54-
const { isCreatingAccount, passwordErrorMessage } = this.state;
72+
const { isCreatingAccount, passwordErrorMessage, usePassword } = this.state;
5573
const submitClasses = classNames('button', 'button-primary', {
5674
pending: this.props.authPending,
5775
});
5876

5977
const signUpText = 'Sign up';
6078
const logInText = 'Log in';
61-
const buttonLabel = isCreatingAccount ? signUpText : logInText;
79+
const headerLabel = isCreatingAccount ? signUpText : logInText;
80+
const buttonLabel = isCreatingAccount
81+
? signUpText
82+
: !isCreatingAccount && !usePassword
83+
? 'Log in with email'
84+
: logInText;
85+
const wpccLabel =
86+
(isCreatingAccount ? signUpText : logInText) + ' with WordPress.com';
6287
const helpLinkLabel = isCreatingAccount ? logInText : signUpText;
6388
const helpMessage = isCreatingAccount
6489
? 'Already have an account?'
@@ -81,7 +106,7 @@ export class Auth extends Component<Props> {
81106
.
82107
</>
83108
) : (
84-
'Could not log in with the provided email address and password.'
109+
'Could not log in with the provided credentials.'
85110
);
86111

87112
const mainClasses = classNames('login', {
@@ -92,14 +117,14 @@ export class Auth extends Component<Props> {
92117
return (
93118
<div className={mainClasses}>
94119
{isElectron && isMac && <div className="login__draggable-area" />}
95-
<div className="accountRequested">
120+
<div className="account-requested">
96121
<MailIcon />
97-
<p className="accountRequested__message">
122+
<p className="account-requested__message">
98123
We&apos;ve sent an email to{' '}
99124
<strong>{this.props.emailSentTo}</strong>. Please check your inbox
100125
and follow the instructions.
101126
</p>
102-
<p className="accountRequested__footer">
127+
<p className="account-requested__footer">
103128
Didn&apos;t get an email? You may already have an account
104129
associated with this email address. Contact{' '}
105130
<a
@@ -124,12 +149,74 @@ export class Auth extends Component<Props> {
124149
);
125150
}
126151

152+
if (
153+
this.props.loginRequested ||
154+
this.props.isCompletingLogin ||
155+
this.props.hasCodeError
156+
) {
157+
return (
158+
<div className={mainClasses}>
159+
{isElectron && isMac && <div className="login__draggable-area" />}
160+
<div className="account-requested">
161+
<form className="login__form" onSubmit={this.onSubmitCode}>
162+
<MailIcon />
163+
<p className="account-requested__message">
164+
We&apos;ve sent a code to{' '}
165+
<strong>{this.props.emailSentTo}</strong>. The code will be
166+
valid for a few minutes.
167+
</p>
168+
{(passwordErrorMessage || this.props.hasCodeError) && (
169+
<p className="login__auth-message is-error">
170+
{passwordErrorMessage
171+
? passwordErrorMessage
172+
: 'Could not log in. Check the code and try again.'}
173+
</p>
174+
)}
175+
<input
176+
type="text"
177+
className="account-requested__code"
178+
placeholder="Code"
179+
maxLength={6}
180+
autoFocus
181+
ref={(ref) => (this.codeInput = ref)}
182+
></input>
183+
<button className="button button-primary" type="submit">
184+
{this.props.isCompletingLogin ? (
185+
<Spinner isWhite={true} size={20} thickness={5} />
186+
) : (
187+
'Log in'
188+
)}
189+
</button>
190+
<Fragment>
191+
<div className="or-section">
192+
<span className="or">Or</span>
193+
<span className="or-line"></span>
194+
</div>
195+
<button
196+
className="button button-secondary account-requested__password-button"
197+
onClick={this.togglePassword}
198+
>
199+
Enter password
200+
</button>
201+
</Fragment>
202+
<button
203+
onClick={this.clearRequestedAccount}
204+
className="button-borderless"
205+
>
206+
Go Back
207+
</button>
208+
</form>
209+
</div>
210+
</div>
211+
);
212+
}
213+
127214
return (
128215
<div className={mainClasses}>
129216
{isElectron && isMac && <div className="login__draggable-area" />}
130217
<SimplenoteLogo />
131218
<form className="login__form" onSubmit={this.onSubmit}>
132-
<h1>{buttonLabel}</h1>
219+
<h1>{headerLabel}</h1>
133220
{!this.state.onLine && (
134221
<p className="login__auth-message is-error">Offline</p>
135222
)}
@@ -204,7 +291,7 @@ export class Auth extends Component<Props> {
204291
className="login__auth-message is-error"
205292
data-error-name="too-many-requests"
206293
>
207-
Too many log in attempts. Try again later.
294+
Too many login attempts. Try again later.
208295
</p>
209296
)}
210297
{(this.props.hasInvalidCredentials || this.props.hasLoginError) && (
@@ -236,7 +323,7 @@ export class Auth extends Component<Props> {
236323
required
237324
autoFocus
238325
/>
239-
{!isCreatingAccount && (
326+
{!isCreatingAccount && usePassword && (
240327
<>
241328
<label className="login__field" htmlFor="login__field-password">
242329
Password
@@ -270,7 +357,20 @@ export class Auth extends Component<Props> {
270357
)}
271358
</button>
272359

273-
{!isCreatingAccount && (
360+
{usePassword && (
361+
<Fragment>
362+
<a
363+
className="login__forgot"
364+
href="#"
365+
rel="noopener noreferrer"
366+
onClick={this.togglePassword}
367+
>
368+
Log in with email
369+
</a>
370+
</Fragment>
371+
)}
372+
373+
{!isCreatingAccount && usePassword && (
274374
<a
275375
className="login__forgot"
276376
href="https://app.simplenote.com/forgot/"
@@ -286,7 +386,7 @@ export class Auth extends Component<Props> {
286386
<span className="or">Or</span>
287387
<span className="or-line"></span>
288388
<button className="wpcc-button" onClick={this.onWPLogin}>
289-
{buttonLabel} with WordPress.com
389+
{wpccLabel}
290390
</button>
291391
</Fragment>
292392
)}
@@ -364,7 +464,7 @@ export class Auth extends Component<Props> {
364464
this.props.resetErrors();
365465
};
366466

367-
onSubmit = (event) => {
467+
onSubmit = (event: React.FormEvent) => {
368468
event.preventDefault();
369469

370470
// clear any existing error messages on submit
@@ -383,7 +483,7 @@ export class Auth extends Component<Props> {
383483
}
384484
} else if (
385485
this.usernameInput.validity.valueMissing ||
386-
this.passwordInput.validity.valueMissing
486+
(this.state.usePassword && this.passwordInput.validity.valueMissing)
387487
) {
388488
this.setState({
389489
passwordErrorMessage: 'Please fill out email and password.',
@@ -406,28 +506,54 @@ export class Auth extends Component<Props> {
406506
return;
407507
}
408508

409-
const password = get(this.passwordInput, 'value');
509+
if (this.state.usePassword) {
510+
const password = get(this.passwordInput, 'value');
511+
512+
// login has slightly more relaxed password rules
513+
if (!this.passwordInput.validity.valid) {
514+
this.setState({
515+
passwordErrorMessage: 'Passwords must contain at least 4 characters.',
516+
});
517+
return;
518+
}
519+
this.setState({ passwordErrorMessage: null });
520+
this.props.login(username, password);
521+
return;
522+
}
523+
524+
// default: magic link login
525+
this.props.requestLogin(username);
526+
};
527+
528+
onSubmitCode = (event: React.FormEvent) => {
529+
event.preventDefault();
530+
this.setState({ authState: 'login-requested' });
531+
this.setState({
532+
passwordErrorMessage: '',
533+
});
410534

411-
// login has slightly more relaxed password rules
412-
if (!this.passwordInput.validity.valid) {
535+
const code = get(this.codeInput, 'value');
536+
const alphanumericRegex = /^[a-zA-Z0-9]{6}$/;
537+
if (!alphanumericRegex.test(code)) {
413538
this.setState({
414-
passwordErrorMessage: 'Passwords must contain at least 4 characters.',
539+
passwordErrorMessage: 'Code must be 6 characters.',
415540
});
416541
return;
417542
}
418543

419-
this.setState({ passwordErrorMessage: null });
420-
this.props.login(username, password);
544+
const email = this.props.emailSentTo;
545+
546+
this.props.completeLogin(email, code);
421547
};
422548

423549
onWPLogin = () => {
424550
const redirectUrl = encodeURIComponent(config.wpcc_redirect_url);
425-
this.authState = `app-${cryptoRandomString(20)}`;
426-
const authUrl = `https://public-api.wordpress.com/oauth2/authorize?client_id=${config.wpcc_client_id}&redirect_uri=${redirectUrl}&response_type=code&scope=global&state=${this.authState}`;
551+
this.setState({ authState: `app-${cryptoRandomString(20)}` });
552+
const authUrl = `https://public-api.wordpress.com/oauth2/authorize?client_id=${config.wpcc_client_id}&redirect_uri=${redirectUrl}&response_type=code&scope=global&state=${this.state.authState}`;
427553

428554
window.electron.send('wpLogin', authUrl);
429555

430-
window.electron.receive('wpLogin', (url) => {
556+
window.electron.receive('wpLogin', (url: string) => {
431557
const { searchParams } = new URL(url);
432558

433559
const errorCode = searchParams.get('error')
@@ -454,34 +580,43 @@ export class Auth extends Component<Props> {
454580
return this.authError('An error was encountered while signing in.');
455581
}
456582

457-
if (authState !== this.authState) {
583+
if (authState !== this.state.authState) {
458584
return;
459585
}
460586
this.props.tokenLogin(userEmail, simperiumToken);
461587
});
462588
};
463589

464-
authError = (errorMessage) => {
590+
authError = (errorMessage: string) => {
465591
this.setState({
466592
passwordErrorMessage: errorMessage,
467593
});
468594
};
469595

470-
onForgot = (event) => {
596+
onForgot = (event: React.MouseEvent) => {
471597
event.preventDefault();
472598
window.open(
473-
event.currentTarget.href,
474-
null,
599+
(event.currentTarget as HTMLAnchorElement).href,
600+
undefined,
475601
'width=640,innerWidth=640,height=480,innerHeight=480,useContentSize=true,chrome=yes,centerscreen=yes'
476602
);
477603
};
478604

479-
toggleSignUp = (event) => {
605+
toggleSignUp = (event: React.MouseEvent) => {
480606
event.preventDefault();
481607
this.props.resetErrors();
482608
this.setState({
483609
passwordErrorMessage: '',
484610
});
485611
this.setState({ isCreatingAccount: !this.state.isCreatingAccount });
486612
};
613+
togglePassword = (event: React.MouseEvent) => {
614+
event.preventDefault();
615+
this.props.resetErrors();
616+
this.setState({
617+
passwordErrorMessage: '',
618+
emailForPasswordForm: this.props.emailSentTo,
619+
usePassword: !this.state.usePassword,
620+
});
621+
};
487622
}

0 commit comments

Comments
 (0)