add email auth ui

This commit is contained in:
Vyrtsev Mikhail
2019-07-06 13:58:58 -05:00
committed by Umputun
parent c3253bec85
commit ac60bb8750
7 changed files with 311 additions and 1 deletions
+19
View File
@@ -12,8 +12,27 @@ const __loginAnonymously = (username: string): Promise<User | null> => {
return fetcher.get<User>({ url, withCredentials: true, overriddenApiBase: '' });
};
const __loginViaEmail = (token: string): Promise<User | null> => {
const url = `/auth/email/login?token=${token}`;
return fetcher.get<User>({ url, withCredentials: true, overriddenApiBase: '' });
};
/**
* First step of two of `email` authorization
*
* @param username userrname
* @param address email address
*/
export const sendEmailVerificationRequest = (username: string, address: string): Promise<void> => {
const url = `/auth/email/login?id=${siteId}&user=${encodeURIComponent(username)}&address=${encodeURIComponent(
address
)}`;
return fetcher.get({ url, withCredentials: true, overriddenApiBase: '' });
};
export const logIn = (provider: AuthProvider): Promise<User | null> => {
if (provider.name === 'anonymous') return __loginAnonymously(provider.username);
if (provider.name === 'email') return __loginViaEmail(provider.token);
return new Promise<User | null>((resolve, reject) => {
const url = `${BASE_URL}/auth/${provider.name}/login?from=${encodeURIComponent(
+1
View File
@@ -22,6 +22,7 @@ export const PROVIDER_NAMES: { [P in AuthProvider['name']]: string } = {
yandex: 'Yandex',
dev: 'Dev',
anonymous: 'Anonymous',
email: 'Email',
};
/** locastorage key for collapsed comments */
+2 -1
View File
@@ -119,7 +119,8 @@ export type AuthProvider =
| { name: 'github' }
| { name: 'yandex' }
| { name: 'dev' }
| { name: 'anonymous'; username: string };
| { name: 'anonymous'; username: string }
| { name: 'email'; token: string };
export type BlockTTL = 'permanently' | '43200m' | '10080m' | '1440m';
@@ -0,0 +1,44 @@
.auth-panel-email-login-form {
padding: 0.4em 0.6em;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
}
.auth-panel-email-login-form__input {
width: 15em;
margin: 0.1em;
}
.auth-panel-email-login-form__submit {
background: none;
border: none;
padding: 0;
padding: 0.1em;
margin-top: 0.2em;
color: currentColor;
font: inherit;
cursor: pointer;
}
.auth-panel-email-login-form__submit:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.auth-panel-email-login-form__back-button {
color: #259c9a;
cursor: pointer;
margin-left: 0.1rem;
margin-bottom: 0.5rem;
&:hover {
opacity: 0.8;
}
}
.auth-panel-email-login-form__error {
color: #9a0000;
text-align: center;
margin-top: 1em;
}
@@ -0,0 +1,215 @@
/** @jsx h */
import { h, Component, RenderableProps } from 'preact';
import b from 'bem-react-helper';
import { Theme, User } from '@app/common/types';
import { sendEmailVerificationRequest } from '@app/common/api';
import { extractErrorMessageFromResponse } from '@app/utils/errorUtils';
import { connect } from 'preact-redux';
import { getHandleClickProps } from '@app/common/accessibility';
const mapStateToProps = () => ({
sendEmailVerification: sendEmailVerificationRequest,
});
type Props = {
onSignIn(token: string): Promise<User | null>;
onSuccess?(user: User): Promise<void>;
theme: Theme;
className?: string;
} & ReturnType<typeof mapStateToProps>;
interface State {
usernameValue: string;
addressValue: string;
tokenValue: string;
verificationSent: boolean;
loading: boolean;
error: string | null;
}
export class EmailLoginForm extends Component<Props, State> {
static usernameRegex = /^[a-zA-Z][\w ]+$/;
static emailRegex = /[^@]+@[^.]+\..+/;
inputRef?: HTMLInputElement;
tokenRef?: HTMLInputElement;
constructor(props: Props) {
super(props);
this.state = {
usernameValue: '',
addressValue: '',
tokenValue: '',
verificationSent: false,
loading: false,
error: null,
};
this.onVerificationSubmit = this.onVerificationSubmit.bind(this);
this.onSubmit = this.onSubmit.bind(this);
this.onUsernameChange = this.onUsernameChange.bind(this);
this.onAddressChange = this.onAddressChange.bind(this);
this.onTokenChange = this.onTokenChange.bind(this);
this.goBack = this.goBack.bind(this);
}
async onVerificationSubmit(e: Event) {
e.preventDefault();
this.setState({ loading: true });
try {
await this.props.sendEmailVerification(this.state.usernameValue, this.state.addressValue);
this.setState({ verificationSent: true });
setTimeout(() => {
this.tokenRef && this.tokenRef.focus();
}, 100);
} catch (e) {
this.setState({ error: extractErrorMessageFromResponse(e) });
} finally {
this.setState({ loading: false });
}
}
async onSubmit(e: Event) {
e.preventDefault();
try {
this.setState({ loading: true });
const user = await this.props.onSignIn(this.state.tokenValue);
if (!user) {
this.setState({ error: 'No user was found' });
return;
}
this.props.onSuccess && this.props.onSuccess(user);
} catch (e) {
this.setState({ error: extractErrorMessageFromResponse(e) });
} finally {
this.setState({ loading: false });
}
}
onUsernameChange(e: Event) {
this.setState({ error: null, usernameValue: (e.target as HTMLInputElement).value });
}
onAddressChange(e: Event) {
this.setState({ error: null, addressValue: (e.target as HTMLInputElement).value });
}
onTokenChange(e: Event) {
this.setState({ error: null, tokenValue: (e.target as HTMLInputElement).value });
}
goBack() {
this.setState({
tokenValue: '',
error: null,
verificationSent: false,
});
setTimeout(() => {
this.inputRef && this.inputRef.focus();
}, 100);
}
getForm1InvalidReason(): string | null {
if (this.state.loading) return 'Loading...';
const username = this.state.usernameValue;
if (username.length < 3) return 'Username must be at least 3 characters long';
if (!EmailLoginForm.usernameRegex.test(username))
return 'Username must start from the letter and contain only latin letters, numbers, underscores, and spaces';
if (!EmailLoginForm.emailRegex.test(this.state.addressValue)) return 'Address should be valid email address';
return null;
}
getForm2InvalidReason(): string | null {
if (this.state.loading) return 'Loading...';
if (this.state.tokenValue.length === 0) return 'Token field must not be empty';
return null;
}
componentDidMount() {
setTimeout(() => {
this.inputRef && this.inputRef.focus();
}, 100);
}
render(props: RenderableProps<Props>) {
// TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname
let className = b('auth-panel-email-login-form', {}, { theme: props.theme });
if (props.className) {
className += ' ' + b('auth-panel-email-login-form', {}, { theme: props.theme });
}
const form1InvalidReason = this.getForm1InvalidReason();
if (!this.state.verificationSent)
return (
<form className={className} onSubmit={this.onVerificationSubmit}>
{/*
* We adding hidden span element to bear with DropDown's onOutSideClick handler.
* This function checks if element that was clicked is a children of it's root component.
* And the problem is that by the time handler gets executed our target element is not a
* part of a dom, so handler suggests that we clicked somewhere outside and hides dropdown
*/}
<span
className="auth-panel-email-login-form__back-button"
role="button"
{...getHandleClickProps(this.goBack)}
style={{ display: 'none' }}
>
{'< Back'}
</span>
<input
className="auth-panel-email-login-form__input"
ref={ref => (this.inputRef = ref)}
type="text"
placeholder="Username"
value={this.state.usernameValue}
onInput={this.onUsernameChange}
/>
<input
className="auth-panel-email-login-form__input"
type="text"
placeholder="Email Address"
value={this.state.addressValue}
onInput={this.onAddressChange}
/>
<input
className="auth-panel-email-login-form__submit"
type="submit"
value="Send Verification"
title={form1InvalidReason || ''}
disabled={form1InvalidReason !== null}
/>
{this.state.error && <div class="auth-panel-email-login-form__error">{this.state.error}</div>}
</form>
);
const form2InvalidReason = this.getForm2InvalidReason();
return (
<form className={className} onSubmit={this.onSubmit}>
<span className="auth-panel-email-login-form__back-button" role="button" {...getHandleClickProps(this.goBack)}>
{'< Back'}
</span>
<input
className="auth-panel-email-login-form__input"
ref={ref => (this.tokenRef = ref)}
type="text"
placeholder="Token"
value={this.state.tokenValue}
onInput={this.onTokenChange}
/>
<input
className="auth-panel-email-login-form__submit"
type="submit"
value="Confirm"
title={form2InvalidReason || ''}
disabled={form2InvalidReason !== null}
/>
{this.state.error && <div class="auth-panel-email-login-form__error">{this.state.error}</div>}
</form>
);
}
}
export const EmailLoginFormConnected = connect(mapStateToProps)(EmailLoginForm);
@@ -0,0 +1,3 @@
export { EmailLoginForm, EmailLoginFormConnected } from './auth-panel__email-login-form';
require('./auth-panel__email-login-form.scss');
@@ -11,6 +11,7 @@ import Dropdown, { DropdownItem } from '@app/components/dropdown';
import { Button } from '@app/components/button';
import { UserID } from './__user-id';
import { AnonymousLoginForm } from './__anonymous-login-form';
import { EmailLoginFormConnected as EmailLoginForm } from './__email-login-form';
import { StoreState } from '@app/store';
export interface Props {
@@ -49,6 +50,7 @@ export class AuthPanel extends Component<Props, State> {
this.toggleCommentsAvailability = this.toggleCommentsAvailability.bind(this);
this.onSortChange = this.onSortChange.bind(this);
this.onSignIn = this.onSignIn.bind(this);
this.onEmailSignIn = this.onEmailSignIn.bind(this);
this.handleAnonymousLoginFormSubmut = this.handleAnonymousLoginFormSubmut.bind(this);
this.handleOAuthLogin = this.handleOAuthLogin.bind(this);
this.toggleUserInfoVisibility = this.toggleUserInfoVisibility.bind(this);
@@ -94,6 +96,10 @@ export class AuthPanel extends Component<Props, State> {
this.props.onSignIn(provider);
}
onEmailSignIn(token: string) {
return this.props.onSignIn({ name: 'email', token });
}
async handleAnonymousLoginFormSubmut(username: string) {
this.onSignIn({ name: 'anonymous', username });
}
@@ -173,6 +179,27 @@ export class AuthPanel extends Component<Props, State> {
);
}
if (provider === 'email') {
return (
<span>
{comma}{' '}
<Dropdown
title={PROVIDER_NAMES[provider]}
titleClass="auth-panel__pseudo-link"
theme={this.props.theme}
>
<DropdownItem>
<EmailLoginForm
onSignIn={this.onEmailSignIn}
theme={this.props.theme}
className="auth-panel__email-login-form"
/>
</DropdownItem>
</Dropdown>
</span>
);
}
return (
<span>
{comma}