diff --git a/frontend/app/common/api.ts b/frontend/app/common/api.ts index 490bb2f6..a2e62a28 100644 --- a/frontend/app/common/api.ts +++ b/frontend/app/common/api.ts @@ -12,8 +12,27 @@ const __loginAnonymously = (username: string): Promise => { return fetcher.get({ url, withCredentials: true, overriddenApiBase: '' }); }; +const __loginViaEmail = (token: string): Promise => { + const url = `/auth/email/login?token=${token}`; + return fetcher.get({ 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 => { + 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 => { if (provider.name === 'anonymous') return __loginAnonymously(provider.username); + if (provider.name === 'email') return __loginViaEmail(provider.token); return new Promise((resolve, reject) => { const url = `${BASE_URL}/auth/${provider.name}/login?from=${encodeURIComponent( diff --git a/frontend/app/common/constants.ts b/frontend/app/common/constants.ts index 217cbd27..6e22940f 100644 --- a/frontend/app/common/constants.ts +++ b/frontend/app/common/constants.ts @@ -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 */ diff --git a/frontend/app/common/types.ts b/frontend/app/common/types.ts index 14095f29..8f69aaa4 100644 --- a/frontend/app/common/types.ts +++ b/frontend/app/common/types.ts @@ -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'; diff --git a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.scss b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.scss new file mode 100644 index 00000000..86a3fea3 --- /dev/null +++ b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.scss @@ -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; +} diff --git a/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx new file mode 100644 index 00000000..6261998c --- /dev/null +++ b/frontend/app/components/auth-panel/__email-login-form/auth-panel__email-login-form.tsx @@ -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; + onSuccess?(user: User): Promise; + theme: Theme; + className?: string; +} & ReturnType; + +interface State { + usernameValue: string; + addressValue: string; + tokenValue: string; + verificationSent: boolean; + loading: boolean; + error: string | null; +} + +export class EmailLoginForm extends Component { + 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) { + // 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 ( +
+ {/* + * 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 + */} + + {'< Back'} + + (this.inputRef = ref)} + type="text" + placeholder="Username" + value={this.state.usernameValue} + onInput={this.onUsernameChange} + /> + + + {this.state.error && } +
+ ); + + const form2InvalidReason = this.getForm2InvalidReason(); + + return ( +
+ + {'< Back'} + + (this.tokenRef = ref)} + type="text" + placeholder="Token" + value={this.state.tokenValue} + onInput={this.onTokenChange} + /> + + {this.state.error && } +
+ ); + } +} + +export const EmailLoginFormConnected = connect(mapStateToProps)(EmailLoginForm); diff --git a/frontend/app/components/auth-panel/__email-login-form/index.ts b/frontend/app/components/auth-panel/__email-login-form/index.ts new file mode 100644 index 00000000..cc47afcf --- /dev/null +++ b/frontend/app/components/auth-panel/__email-login-form/index.ts @@ -0,0 +1,3 @@ +export { EmailLoginForm, EmailLoginFormConnected } from './auth-panel__email-login-form'; + +require('./auth-panel__email-login-form.scss'); diff --git a/frontend/app/components/auth-panel/auth-panel.tsx b/frontend/app/components/auth-panel/auth-panel.tsx index 0e8b1e39..17deac92 100644 --- a/frontend/app/components/auth-panel/auth-panel.tsx +++ b/frontend/app/components/auth-panel/auth-panel.tsx @@ -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 { 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 { 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 { ); } + if (provider === 'email') { + return ( + + {comma}{' '} + + + + + + + ); + } + return ( {comma}