diff --git a/frontend/.size-limit.js b/frontend/.size-limit.js index 84f4756f..2170c43b 100644 --- a/frontend/.size-limit.js +++ b/frontend/.size-limit.js @@ -9,7 +9,7 @@ module.exports = [ }, { path: 'public/remark.css', - limit: '9 KB', + limit: '10 KB', }, { path: 'public/last-comments.mjs', diff --git a/frontend/.stylelintrc.js b/frontend/.stylelintrc.js index 841565f7..ae2dc2da 100644 --- a/frontend/.stylelintrc.js +++ b/frontend/.stylelintrc.js @@ -12,6 +12,8 @@ module.exports = { ignore: ['after-comment'], }, ], + 'selector-pseudo-class-no-unknown': [true, { ignorePseudoClasses: ['global'] }], + 'property-no-unknown': [true, { ignoreProperties: ['composes'] }], 'mavrin/stylelint-declaration-use-css-custom-properties': { cssDefinitions: ['color'], ignoreProperties: ['/^\\$/'], diff --git a/frontend/app/common/api.test.ts b/frontend/app/common/api.test.ts deleted file mode 100644 index c0cbd0e8..00000000 --- a/frontend/app/common/api.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -jest.mock('./settings', () => ({ - siteId: 'remark', -})); - -import { logIn } from './api'; - -describe('api', () => { - it('should open oauth endpoint with right url', () => { - window.open = jest.fn().mockImplementationOnce(jest.fn()); - logIn({ name: 'google' }); - - expect(window.open).toHaveBeenCalledWith( - '/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark' - ); - }); -}); diff --git a/frontend/app/common/api.ts b/frontend/app/common/api.ts index c4d61208..b0ae954c 100644 --- a/frontend/app/common/api.ts +++ b/frontend/app/common/api.ts @@ -1,61 +1,7 @@ import { siteId, url } from './settings'; import { BASE_URL, API_BASE } from './constants'; -import { Config, Comment, Tree, User, BlockedUser, Sorting, AuthProvider, BlockTTL, Image } from './types'; -import { authFetcher, apiFetcher, adminFetcher } from './fetcher'; - -/* Auth methods */ -const FROM_URL = `${window.location.origin}${window.location.pathname}?selfClose`; - -const __loginAnonymously = (username: string): Promise => - authFetcher.get('/anonymous/login', { - user: username, - aud: siteId, - from: FROM_URL, - }); - -const __loginViaEmail = (token: string): Promise => authFetcher.get('/email/login', { token }); - -/** - * First step of two of `email` authorization - * - * @param username userrname - * @param address email address - */ -export const sendEmailVerificationRequest = (username: string, address: string): Promise => - authFetcher.get('/email/login', { id: siteId, user: username, address }); - -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 queryString = new URLSearchParams({ from: FROM_URL, site: siteId }); - const newWindow = window.open(`/auth/${provider.name}/login?${queryString}`); - let secondsPass = 0; - const checkMsDelay = 300; - const checkInterval = setInterval(() => { - let shouldProceed; - secondsPass += checkMsDelay; - try { - shouldProceed = (newWindow && newWindow.closed) || secondsPass > 30000; - } catch (e) {} - - if (shouldProceed) { - clearInterval(checkInterval); - - getUser() - .then((user) => { - resolve(user); - }) - .catch(() => { - reject(new Error('User logIn Error')); - }); - } - }, checkMsDelay); - }); -}; - -export const logOut = (): Promise => authFetcher.get('/logout'); +import { Config, Comment, Tree, User, BlockedUser, Sorting, BlockTTL, Image } from './types'; +import { apiFetcher, adminFetcher } from './fetcher'; /* API methods */ diff --git a/frontend/app/common/constants.ts b/frontend/app/common/constants.ts index aede476a..39351519 100644 --- a/frontend/app/common/constants.ts +++ b/frontend/app/common/constants.ts @@ -1,4 +1,4 @@ -import { Sorting, AuthProvider, Theme } from './types'; +import { Sorting, Theme } from './types'; export { BASE_URL, API_BASE, NODE_ID, COMMENT_NODE_CLASSNAME_PREFIX } from './constants.config'; export const LAST_COMMENTS_NODE_CLASSNAME = 'remark42__last-comments'; @@ -6,18 +6,6 @@ export const MAX_SHOWN_ROOT_COMMENTS = 10; export const DEFAULT_SORT: Sorting = '-active'; -/* matches auth providers to UI label */ -export const PROVIDER_NAMES: { [P in AuthProvider['name']]: string } = { - google: 'Google', - twitter: 'Twitter', - facebook: 'Facebook', - github: 'GitHub', - yandex: 'Yandex', - dev: 'Dev', - anonymous: 'Anonymous', - email: 'Email', -}; - /** locastorage key for collapsed comments */ export const LS_COLLAPSE_KEY = '__remarkCollapsed'; diff --git a/frontend/app/common/types.ts b/frontend/app/common/types.ts index cedfd9d8..98f6ef34 100644 --- a/frontend/app/common/types.ts +++ b/frontend/app/common/types.ts @@ -100,13 +100,17 @@ export interface Tree { info: PostInfo; } +export type OAuthProvider = 'facebook' | 'twitter' | 'google' | 'yandex' | 'github' | 'microsoft' | 'dev'; +export type FormProvider = 'email' | 'anonymous'; +export type Provider = OAuthProvider | FormProvider; + export interface Config { version: string; + auth_providers: Provider[]; edit_duration: number; max_comment_size: number; admins: string[]; admin_email: string; - auth_providers: AuthProvider['name'][]; low_score: number; critical_score: number; positive_score: boolean; @@ -120,16 +124,6 @@ export interface Config { export type Sorting = '-time' | '+time' | '-active' | '+active' | '-score' | '+score' | '-controversy' | '+controversy'; -export type AuthProvider = - | { name: 'google' } - | { name: 'facebook' } - | { name: 'github' } - | { name: 'yandex' } - | { name: 'twitter' } - | { name: 'dev' } - | { name: 'anonymous'; username: string } - | { name: 'email'; token: string }; - export type BlockTTL = 'permanently' | '43200m' | '10080m' | '1440m'; export interface BlockingDuration { diff --git a/frontend/app/components/auth-panel/auth-panel.css b/frontend/app/components/auth-panel/auth-panel.css index 49779cf3..9988a8a7 100644 --- a/frontend/app/components/auth-panel/auth-panel.css +++ b/frontend/app/components/auth-panel/auth-panel.css @@ -4,5 +4,4 @@ font-size: 14px; line-height: 16px; align-items: baseline; - padding: 2px 0; } diff --git a/frontend/app/components/auth-panel/auth-panel.test.tsx b/frontend/app/components/auth-panel/auth-panel.test.tsx index ef908534..6fc42597 100644 --- a/frontend/app/components/auth-panel/auth-panel.test.tsx +++ b/frontend/app/components/auth-panel/auth-panel.test.tsx @@ -9,12 +9,8 @@ import type { User } from 'common/types'; import enMessages from 'locales/en.json'; import AuthPanel, { Props } from './auth-panel'; -import { Button } from '../button'; -import { StaticStore } from 'common/static-store'; const DefaultProps = { - providers: ['google', 'github'], - provider: { name: null }, postInfo: { read_only: false, url: 'https://example.com', @@ -69,24 +65,6 @@ describe('', () => { expect(adminAction.text()).toEqual('Show settings'); }); - - it('should render auth for read only post', () => { - StaticStore.config.auth_providers = ['google', 'github']; - - const element = createWrapper({ - ...DefaultProps, - user: null, - postInfo: { ...DefaultProps.postInfo, read_only: true }, - hiddenUsers: { hidden_joe: {} as User }, - } as Props); - - const firstCol = element.find('.auth-panel__column').first(); - const providerButtons = firstCol.find(Button); - - expect(firstCol.text().startsWith('Login:')).toBe(true); - expect(providerButtons.at(0).text()).toBe('Google'); - expect(providerButtons.at(1).text()).toBe('GitHub'); - }); }); describe('For authorized user', () => { diff --git a/frontend/app/components/auth-panel/auth-panel.tsx b/frontend/app/components/auth-panel/auth-panel.tsx index 81d0d63f..50004549 100644 --- a/frontend/app/components/auth-panel/auth-panel.tsx +++ b/frontend/app/components/auth-panel/auth-panel.tsx @@ -3,19 +3,17 @@ import { useSelector } from 'react-redux'; import { FormattedMessage, defineMessages, IntlShape, useIntl } from 'react-intl'; import b from 'bem-react-helper'; -import { User, AuthProvider, Sorting, Theme, PostInfo } from 'common/types'; +import { User, Sorting, Theme, PostInfo } from 'common/types'; import { IS_STORAGE_AVAILABLE, IS_THIRD_PARTY } from 'common/constants'; import { requestDeletion } from 'utils/email'; import postMessage from 'utils/postMessage'; import { getHandleClickProps } from 'common/accessibility'; import { StoreState } from 'store'; -import { ProviderState } from 'store/provider/reducers'; import { Dropdown, DropdownItem } from 'components/dropdown'; import { Button } from 'components/button'; import Auth from 'components/auth'; import useTheme from 'hooks/useTheme'; -import { StaticStore } from 'common/static-store'; export interface OwnProps { user: User | null; @@ -24,7 +22,6 @@ export interface OwnProps { postInfo: PostInfo; onSortChange(s: Sorting): Promise; - onSignIn(p: AuthProvider): Promise; onSignOut(): Promise; onCommentsChangeReadOnlyMode(readOnly: boolean): Promise; onBlockedUsersShow(): void; @@ -34,8 +31,6 @@ export interface OwnProps { export interface Props extends OwnProps { intl: IntlShape; theme: Theme; - providers: AuthProvider['name'][]; - provider: ProviderState; sort: Sorting; } @@ -327,17 +322,7 @@ function getSortArray(currentSort: Sorting, intl: IntlShape) { export default function AuthPanelConnected(props: OwnProps) { const intl = useIntl(); const theme = useTheme(); - const provider = useSelector((state) => state.provider); const sort = useSelector((state) => state.comments.sort); - return ( - - ); + return ; } diff --git a/frontend/app/components/auth/__anonymous-login-form/auth__anonymous-login-form.css b/frontend/app/components/auth/__anonymous-login-form/auth__anonymous-login-form.css deleted file mode 100644 index dc29b9fc..00000000 --- a/frontend/app/components/auth/__anonymous-login-form/auth__anonymous-login-form.css +++ /dev/null @@ -1,18 +0,0 @@ -.auth-anonymous-login-form { - padding: 0.5em 0.7em; - display: flex; - flex-direction: row; - flex-wrap: nowrap; -} - -.auth-anonymous-login-form__input { - width: 9em; -} - -.auth-anonymous-login-form__remember-me { - display: none; -} - -.auth-anonymous-login-form__submit { - margin-left: 0.5em; -} diff --git a/frontend/app/components/auth/__anonymous-login-form/auth__anonymous-login-form.tsx b/frontend/app/components/auth/__anonymous-login-form/auth__anonymous-login-form.tsx deleted file mode 100644 index 3473985e..00000000 --- a/frontend/app/components/auth/__anonymous-login-form/auth__anonymous-login-form.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { h, Component, createRef } from 'preact'; -import b from 'bem-react-helper'; -import { IntlShape, defineMessages, FormattedMessage } from 'react-intl'; -import { Theme } from 'common/types'; - -import { Input } from 'components/input'; -import { Button } from 'components/button'; - -import { validateUserName } from '../validateUserName'; - -interface Props { - onSubmit(username: string): Promise; - theme: Theme; - className?: string; - intl: IntlShape; -} - -interface State { - inputValue: string; - honeyPotValue: boolean; -} - -export const messages = defineMessages({ - lengthLimit: { - id: 'anonymousLoginForm.length-limit', - defaultMessage: 'Username must be at least 3 characters long', - }, - symbolLimit: { - id: 'anonymousLoginForm.symbol-limit', - defaultMessage: 'Username must contain only letters, numbers, underscores or spaces', - }, - userName: { - id: 'anonymousLoginForm.user-name', - defaultMessage: 'Username', - }, -}); - -export class AnonymousLoginForm extends Component { - inputRef = createRef(); - - constructor(props: Props) { - super(props); - this.state = { - inputValue: '', - honeyPotValue: false, - }; - - this.onSubmit = this.onSubmit.bind(this); - this.onChange = this.onChange.bind(this); - this.onCheckedChange = this.onCheckedChange.bind(this); - } - - onSubmit(e: Event) { - e.preventDefault(); - if (this.state.honeyPotValue) { - // what should i do if bot uncovered? - window.location.reload(); - return; - } - this.props.onSubmit(this.state.inputValue); - } - - onChange(e: Event) { - this.setState({ inputValue: (e.target as HTMLInputElement).value }); - } - - getUsernameInvalidReason(): string | null { - const value = this.state.inputValue; - const intl = this.props.intl; - if (value.length < 3) return intl.formatMessage(messages.lengthLimit); - if (!validateUserName(value)) return intl.formatMessage(messages.symbolLimit); - return null; - } - - onCheckedChange(e: Event) { - this.setState({ honeyPotValue: (e.target as HTMLInputElement).checked }); - } - - componentDidUpdate() { - setTimeout(() => { - this.inputRef.current && this.inputRef.current.focus(); - }, 100); - } - - render() { - const props = this.props; - const intl = props.intl; - // TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname - let className = b('auth-anonymous-login-form', {}, { theme: props.theme }); - if (props.className) { - className += ` ${b('auth-anonymous-login-form', {}, { theme: props.theme })}`; - } - - const usernameInvalidReason = this.getUsernameInvalidReason(); - - return ( -
- - {/* honeypot input */} - - -
- ); - } -} diff --git a/frontend/app/components/auth/__anonymous-login-form/index.ts b/frontend/app/components/auth/__anonymous-login-form/index.ts deleted file mode 100644 index 4b24d0b8..00000000 --- a/frontend/app/components/auth/__anonymous-login-form/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -import './auth__anonymous-login-form.css'; - -export { AnonymousLoginForm } from './auth__anonymous-login-form'; diff --git a/frontend/app/components/auth/__email-login-form/auth__email-login-form.css b/frontend/app/components/auth/__email-login-form/auth__email-login-form.css deleted file mode 100644 index 305865dd..00000000 --- a/frontend/app/components/auth/__email-login-form/auth__email-login-form.css +++ /dev/null @@ -1,61 +0,0 @@ -.auth-email-login-form { - padding: 0.35em 0.55em; - display: flex; - flex-direction: column; - flex-wrap: nowrap; -} - -.auth-email-login-form__input, -.auth-email-login-form__token-input { - width: 12rem; - margin: 2px; -} - -.auth-email-login-form__token-input { - resize: vertical; - border: 1px solid var(--color31); - padding: 4px; - font-family: inherit; - font-size: 0.8em; - font-weight: normal; - line-height: 1.5; - - &:focus { - box-shadow: 0 0 0 2px var(--color47); - border-color: var(--color15); - outline: none; - } -} - -.auth-email-login-form__submit { - margin: 0.3rem 2px 2px; -} - -.auth-email-login-form__back-button { - text-align: left; - margin-left: 0.1rem; - margin-bottom: 0.5rem; - - &::before { - content: '◄'; - display: inline-block; - margin-right: 3px; - } -} - -.auth-email-login-form__error { - margin: 4px 2px; - padding: 6px 8px; - font-weight: normal; - line-height: 1.2; -} - -.auth-email-login-form_theme_dark .auth-email-login-form__error { - background: var(--color28); - color: var(--color27); -} - -.auth-email-login-form_theme_light .auth-email-login-form__error { - background: var(--color26); - color: var(--color25); -} diff --git a/frontend/app/components/auth/__email-login-form/auth__email-login-form.test.tsx b/frontend/app/components/auth/__email-login-form/auth__email-login-form.test.tsx deleted file mode 100644 index e118d50c..00000000 --- a/frontend/app/components/auth/__email-login-form/auth__email-login-form.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { h } from 'preact'; -import { mount, ReactWrapper } from 'enzyme'; -import { EmailLoginFormConnected as EmailLoginForm, Props, State } from './auth__email-login-form'; -import { IntlProvider } from 'react-intl'; - -import { validToken } from '__stubs__/jwt'; -import { LS_EMAIL_KEY } from 'common/constants'; -import { User } from 'common/types'; -import { sleep } from 'utils/sleep'; -import { sendEmailVerificationRequest } from 'common/api'; -import enMessages from 'locales/en.json'; - -jest.mock('utils/jwt', () => ({ - isJwtExpired: jest - .fn() - .mockImplementationOnce(() => true) - .mockImplementationOnce(() => false) - .mockImplementationOnce(() => true), -})); - -jest.mock('common/api'); - -const sendEmailVerificationRequestMock = sendEmailVerificationRequest as jest.Mock< - ReturnType ->; - -function simulateInput(input: ReactWrapper, value: string) { - input.getDOMNode().value = value; - input.simulate('input'); -} - -describe('EmailLoginForm', () => { - const testUser = {} as User; - const onSuccess = jest.fn(async () => undefined); - const onSignIn = jest.fn(async () => testUser); - - beforeEach(() => { - sendEmailVerificationRequestMock.mockReset(); - }); - - it('works', async () => { - sendEmailVerificationRequestMock.mockResolvedValueOnce(); - const el = mount( - - - - ); - simulateInput(el.find(`input[name="email"]`), 'someone@example.com'); - simulateInput(el.find(`input[name="username"]`), 'someone'); - el.find('form').simulate('submit'); - await sleep(100); - expect(sendEmailVerificationRequestMock).toBeCalledWith('someone', 'someone@example.com'); - el.update(); - simulateInput(el.find(`textarea[name="token"]`), 'abcd'); - - el.find('form').simulate('submit'); - await sleep(100); - expect(onSignIn).toBeCalledWith('abcd'); - expect(onSuccess).toBeCalledWith(testUser); - //test that email is saved in local storage after email login - expect(localStorage.getItem(LS_EMAIL_KEY)).toEqual('someone@example.com'); - }); - - it('should send form by pasting token', async () => { - sendEmailVerificationRequestMock.mockResolvedValueOnce(); - const onSignIn = jest.fn(async () => testUser); - - const wrapper = mount( - - - - ); - simulateInput(wrapper.find(`input[name="email"]`), 'someone@example.com'); - simulateInput(wrapper.find(`input[name="username"]`), 'someone'); - wrapper.find('form').simulate('submit'); - await sleep(100); - wrapper.update(); - simulateInput(wrapper.find(`textarea[name="token"]`), validToken); - await sleep(100); - wrapper.update(); - expect(onSignIn).toBeCalledWith(validToken); - }); - - it('should show error "Token is expired" on paste', async () => { - sendEmailVerificationRequestMock.mockResolvedValueOnce(); - const onSignIn = jest.fn(async () => testUser); - - const wrapper = mount( - - - - ); - simulateInput(wrapper.find(`input[name="email"]`), 'someone@example.com'); - simulateInput(wrapper.find(`input[name="username"]`), 'someone'); - wrapper.find('form').simulate('submit'); - await sleep(100); - wrapper.update(); - wrapper.find('textarea').getDOMNode().value = validToken; - wrapper.find('textarea').simulate('input'); - - expect(wrapper.find('.auth-email-login-form__error').text()).toBe('Token is expired'); - }); -}); diff --git a/frontend/app/components/auth/__email-login-form/auth__email-login-form.tsx b/frontend/app/components/auth/__email-login-form/auth__email-login-form.tsx deleted file mode 100644 index bfc652b7..00000000 --- a/frontend/app/components/auth/__email-login-form/auth__email-login-form.tsx +++ /dev/null @@ -1,277 +0,0 @@ -import { h, Component, createRef } from 'preact'; -import { forwardRef } from 'preact/compat'; -import b from 'bem-react-helper'; -import { Theme, User } from 'common/types'; -import { LS_EMAIL_KEY } from 'common/constants'; -import { sendEmailVerificationRequest } from 'common/api'; -import { extractErrorMessageFromResponse } from 'utils/errorUtils'; -import { getHandleClickProps } from 'common/accessibility'; -import { sleep } from 'utils/sleep'; -import TextareaAutosize from 'components/textarea-autosize'; -import { Input } from 'components/input'; -import { Button } from 'components/button'; -import { isJwtExpired } from 'utils/jwt'; -import { defineMessages, IntlShape, useIntl, FormattedMessage } from 'react-intl'; - -import { validateUserName } from '../validateUserName'; -import { messages as loginForm } from '../__anonymous-login-form/auth__anonymous-login-form'; - -interface OwnProps { - onSignIn(token: string): Promise; - onSuccess?(user: User): Promise; - theme: Theme; - className?: string; -} - -export type Props = OwnProps & { intl: IntlShape; sendEmailVerification: typeof sendEmailVerificationRequest }; - -export interface State { - usernameValue: string; - addressValue: string; - tokenValue: string; - verificationSent: boolean; - loading: boolean; - error: string | null; -} - -const messages = defineMessages({ - expiredToken: { - id: 'emailLoginForm.expired-token', - defaultMessage: 'Token is expired', - }, - userNotFound: { - id: 'emailLoginForm.user-not-found', - defaultMessage: 'No user was found', - }, - loading: { - id: 'emailLoginForm.loading', - defaultMessage: 'Loading...', - }, - invalidEmail: { - id: 'emailLoginForm.invalid-email', - defaultMessage: 'Address should be valid email address', - }, - emptyToken: { - id: 'emailLoginForm.empty-token', - defaultMessage: 'Token field must not be empty', - }, - emailAddress: { - id: 'emailLoginForm.email-address', - defaultMessage: 'Email Address', - }, - token: { - id: 'emailLoginForm.token', - defaultMessage: 'Token', - }, -}); - -export class EmailLoginForm extends Component { - static emailRegex = /[^@]+@[^.]+\..+/; - - usernameInputRef = createRef(); - tokenRef = createRef(); - - state = { - usernameValue: '', - addressValue: '', - tokenValue: '', - verificationSent: false, - loading: false, - error: null, - }; - - focus = async () => { - await sleep(100); - - if (this.usernameInputRef.current) { - this.usernameInputRef.current.focus(); - return; - } - - this.tokenRef.current?.select(); - }; - - onVerificationSubmit = async (e: Event) => { - e.preventDefault(); - this.setState({ loading: true, error: null }); - try { - await this.props.sendEmailVerification(this.state.usernameValue, this.state.addressValue); - this.setState({ verificationSent: true }); - await sleep(100); - this.tokenRef.current?.focus(); - } catch (e) { - this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) }); - } finally { - this.setState({ loading: false }); - } - }; - - async sendForm(token: string = this.state.tokenValue) { - const intl = this.props.intl; - try { - this.setState({ loading: true }); - const user = await this.props.onSignIn(token); - if (!user) { - this.setState({ error: intl.formatMessage(messages.userNotFound) }); - return; - } - this.setState({ verificationSent: false, tokenValue: '' }); - localStorage.setItem(LS_EMAIL_KEY, this.state.addressValue); - if (this.props.onSuccess) { - await this.props.onSuccess(user); - } - } catch (e) { - this.setState({ error: extractErrorMessageFromResponse(e, this.props.intl) }); - } finally { - this.setState({ loading: false }); - } - } - - onSubmit = async (e: Event) => { - e.preventDefault(); - this.sendForm(); - }; - - 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) => { - const intl = this.props.intl; - const { value } = e.target as HTMLInputElement; - - this.setState({ error: null, tokenValue: value }); - - try { - if (value.length > 0 && isJwtExpired(value)) { - this.setState({ error: intl.formatMessage(messages.expiredToken) }); - return; - } - this.sendForm(value); - } catch (e) {} - }; - - goBack = async () => { - // Wait for finding back button in DOM by dropbox - // It prevents dropdown from closing, because if dropdown doesn't find clicked element it closes - await sleep(0); - - this.setState({ - tokenValue: '', - error: null, - verificationSent: false, - }); - - // Wait for rendering username+email step to find user input - await sleep(0); - - if (this.usernameInputRef.current) { - this.usernameInputRef.current.focus(); - } - }; - - getForm1InvalidReason(): string | null { - const intl = this.props.intl; - if (this.state.loading) return intl.formatMessage(messages.loading); - const username = this.state.usernameValue; - if (username.length < 3) return intl.formatMessage(loginForm.lengthLimit); - if (!validateUserName(username)) return intl.formatMessage(loginForm.symbolLimit); - if (!EmailLoginForm.emailRegex.test(this.state.addressValue)) return intl.formatMessage(messages.invalidEmail); - return null; - } - - getForm2InvalidReason(): string | null { - const intl = this.props.intl; - if (this.state.loading) return intl.formatMessage(messages.loading); - if (this.state.tokenValue.length === 0) return intl.formatMessage(messages.emptyToken); - return null; - } - - render(props: Props) { - const intl = props.intl; - // TODO: will be great to `b` to accept `string | undefined | (string|undefined)[]` as classname - let className = b('auth-email-login-form', {}, { theme: props.theme }); - if (props.className) { - className += ` ${b('auth-email-login-form', {}, { theme: props.theme })}`; - } - - const form1InvalidReason = this.getForm1InvalidReason(); - - if (!this.state.verificationSent) - return ( -
- - - {this.state.error &&
{this.state.error}
} - -
- ); - - const form2InvalidReason = this.getForm2InvalidReason(); - - return ( -
- - - {this.state.error &&
{this.state.error}
} - - - ); - } -} - -export type EmailLoginFormRef = EmailLoginForm; - -export const EmailLoginFormConnected = forwardRef((props, ref) => { - const intl = useIntl(); - return ; -}); diff --git a/frontend/app/components/auth/__email-login-form/index.ts b/frontend/app/components/auth/__email-login-form/index.ts deleted file mode 100644 index 6fb90c55..00000000 --- a/frontend/app/components/auth/__email-login-form/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import './auth__email-login-form.css'; - -export { EmailLoginForm, EmailLoginFormConnected } from './auth__email-login-form'; -export type { EmailLoginFormRef } from './auth__email-login-form'; diff --git a/frontend/app/components/auth/auth.api.ts b/frontend/app/components/auth/auth.api.ts new file mode 100644 index 00000000..8b0b7a25 --- /dev/null +++ b/frontend/app/components/auth/auth.api.ts @@ -0,0 +1,28 @@ +import type { User } from 'common/types'; + +import { authFetcher } from 'common/fetcher'; +import { siteId } from 'common/settings'; + +const EMAIL_SIGNIN_ENDPOINT = '/email/login'; + +export function anonymousSignin(user: string): Promise { + return authFetcher.get('/anonymous/login', { user, aud: siteId }); +} + +/** + * First step of two of `email` authorization + */ +export function emailSignin(email: string, username: string): Promise { + return authFetcher.get(EMAIL_SIGNIN_ENDPOINT, { address: email, user: username }); +} + +/** + * Second step of two of `email` authorization + */ +export function verifyEmailSignin(token: string): Promise { + return authFetcher.get(EMAIL_SIGNIN_ENDPOINT, { token }); +} + +export function logout(): Promise { + return authFetcher.get('/logout'); +} diff --git a/frontend/app/components/auth/auth.hooks.ts b/frontend/app/components/auth/auth.hooks.ts new file mode 100644 index 00000000..9cdfed49 --- /dev/null +++ b/frontend/app/components/auth/auth.hooks.ts @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; + +export function useDropdown(disableClosing?: boolean) { + const rootRef = useRef(null); + const [showDropdown, setShowDropdown] = useState(false); + const toggleDropdownState = () => { + setShowDropdown((s) => !s); + }; + + useEffect(() => { + if (!showDropdown) { + return; + } + + const dropdownElement = rootRef.current; + + function handleMessageFromParent(evt: MessageEvent) { + if (typeof evt.data !== 'string' || disableClosing) { + return; + } + + try { + const data = JSON.parse(evt.data); + + if (!data.clickOutside) { + return; + } + + setShowDropdown(false); + } catch (e) {} + } + + function handleClickOutside(evt: MouseEvent) { + if (disableClosing || dropdownElement?.contains(evt.target as HTMLDivElement)) { + return; + } + + setShowDropdown(false); + } + + document.addEventListener('click', handleClickOutside); + window.addEventListener('message', handleMessageFromParent); + + return () => { + document.removeEventListener('click', handleClickOutside); + window.removeEventListener('message', handleMessageFromParent); + }; + }, [showDropdown, disableClosing]); + + useEffect(() => { + const dropdownElement = rootRef.current; + + if (!dropdownElement || !showDropdown) { + return; + } + + let prevHeight: number | null = null; + + function handleDropdownContentChange() { + const { top } = dropdownElement.getBoundingClientRect(); + const height = window.scrollY + Math.abs(top) + dropdownElement.scrollHeight + 20; + + if (prevHeight === null && window.innerHeight > height) { + return; + } + + prevHeight = height; + document.body.style.setProperty('min-height', `${height}px`); + } + + const observer = new MutationObserver(handleDropdownContentChange); + + observer.observe(dropdownElement, { attributes: true, childList: true, subtree: true }); + + return () => { + document.body.style.removeProperty('min-height'); + observer.disconnect(); + }; + }, [showDropdown]); + + return [rootRef, showDropdown, toggleDropdownState] as const; +} diff --git a/frontend/app/components/auth/auth.messsages.ts b/frontend/app/components/auth/auth.messsages.ts new file mode 100644 index 00000000..79b3188a --- /dev/null +++ b/frontend/app/components/auth/auth.messsages.ts @@ -0,0 +1,58 @@ +import { defineMessages } from 'react-intl'; + +const messages = defineMessages({ + signin: { + id: 'auth.signin', + defaultMessage: 'Sign In', + }, + or: { + id: 'auth.or', + defaultMessage: 'or', + }, + username: { + id: 'auth.username', + defaultMessage: 'Username', + }, + usernameRestriction: { + id: 'auth.symbols-restriction', + defaultMessage: 'Username must contain only letters, numbers, underscores or spaces', + }, + userNotFound: { + id: 'auth.user-not-found', + defaultMessage: 'No user was found', + }, + emailAddress: { + id: 'auth.email-address', + defaultMessage: 'Email Address', + }, + token: { + id: 'token', + defaultMessage: 'Token', + }, + expiredToken: { + id: 'token.expired', + defaultMessage: 'Token is expired', + }, + invalidToken: { + id: 'token.invalid', + defaultMessage: 'Token is invalid', + }, + oauthTitle: { + id: 'auth.oauth-button', + defaultMessage: 'Sign In with {provider}', + }, + back: { + id: 'auth.back', + defaultMessage: 'Back', + }, + loading: { + id: 'auth.loading', + defaultMessage: 'Loading...', + }, + submit: { + id: 'auth.submit', + defaultMessage: 'Submit', + }, +}); + +export default messages; diff --git a/frontend/app/components/auth/auth.module.css b/frontend/app/components/auth/auth.module.css index d43b026e..66b62609 100644 --- a/frontend/app/components/auth/auth.module.css +++ b/frontend/app/components/auth/auth.module.css @@ -1,4 +1,226 @@ -.auth { - font-size: 14px; - font-weight: 700; +.root { + position: relative; + z-index: 1; + color: var(--color34); +} + +:global(.dark) .root { + color: var(--color16); +} + +.buttonArrow { + position: relative; + padding-left: 8px; + margin-left: 12px; + height: 100%; + + &::before { + position: absolute; + content: ''; + height: 100%; + border-left: 1px solid rgba(var(--white-color), 0.5); + } +} + +.dropdown { + position: absolute; + z-index: 1; + top: calc(100% + 8px); + left: 0; + min-width: 240px; + padding: 16px; + background-color: rgb(var(--white-color)); + box-shadow: 0 10px 15px rgba(var(--black-color), 0.1), 0 -1px 6px rgba(var(--black-color), 0.05); + border-radius: 6px; +} + +:global(.dark) .dropdown { + background-color: var(--color8); +} + +.title { + margin: 0 0 12px; + font-size: 12px; + text-transform: uppercase; + font-weight: bold; + color: rgb(rgb(var(--secondary-text-color))); + text-align: center; +} + +.divider { + position: relative; + display: flex; + width: calc(100% + 32px); + margin: 16px -16px; + height: 16px; + text-transform: uppercase; + + &::before { + position: absolute; + top: 50%; + display: block; + width: 100%; + height: 0; + content: ''; + border-top: 1px solid var(--line-color); + } + + &::after { + position: relative; + display: block; + margin: auto; + padding: 0 12px; + content: attr(title); + font-size: 12px; + color: rgb(var(--secondary-text-color)); + text-transform: uppercase; + background-color: rgb(var(--white-color)); + } +} + +:global(.dark) .divider { + &::after { + background-color: var(--color8); + } +} + +.tabs { + display: flex; + justify-content: center; + margin-bottom: 16px; +} + +.provider { + position: relative; + display: inline-block; + text-transform: uppercase; + color: rgb(var(--secondary-text-color)); + font-weight: bold; + font-size: 12px; + height: 24px; + line-height: 24px; + padding: 0 12px; + cursor: pointer; +} + +.provider + .provider { + margin-left: 16px; +} + +.radio { + position: absolute; + width: 0; + height: 0; + border: 0; + margin: 0; + padding: 0; + appearance: none; + opacity: 0; +} + +:global(.dark) .provider { + color: var(--color32); +} + +.radio:checked + .provider { + /* stylelint-disable-next-line mavrin/stylelint-declaration-use-css-custom-properties */ + color: rgb(var(--primary-color)); + background-color: rgba(var(--primary-color), 0.1); + border-radius: 2px; +} + +:global(.dark) .radio:checked + .provider { + color: rgb(var(--white-color)); + background-color: rgba(var(--primary-color), 0.4); +} + +.row { + width: 100%; + display: flex; + margin-bottom: 12px; +} + +.backButton { + flex: 0; +} + +.backButtonArrow { + display: inline-block; + margin-left: -4px; + margin-right: 2px; +} + +.closeButton { + position: relative; + display: inline-block; + width: 28px; + height: 28px; + border: 0; + padding: 0; + margin-left: auto; + background: unset; + cursor: pointer; + border-radius: 2px; + color: inherit; + + &:hover { + background-color: rgba(var(--primary-color), 0.1); + } +} + +.textarea { + composes: input from 'components/input/input.module.css'; + box-sizing: border-box; + width: 100%; + min-height: 60px; + max-height: 200px; + margin: 0; + padding: 4px 8px; + resize: vertical; + font-size: 16px; + font-family: inherit; +} + +.honeypot { + position: absolute; + padding: 0; + margin: 0; + border: 0; + height: 0; + width: 0; + opacity: 0; +} + +.submit { + width: 100%; + height: 36px; + padding: 0 20px; + font-size: 16px; +} + +.spinner { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid rgba(var(--white-color), 0.2); + border-right-color: rgb(var(--white-color)); + animation: spin 1s linear infinite; +} + +.error { + margin-bottom: 12px; + padding: 6px 8px; + background-color: var(--error-background); + color: var(--error-color); + line-height: 1.2; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } } diff --git a/frontend/app/components/auth/auth.test.tsx b/frontend/app/components/auth/auth.test.tsx deleted file mode 100644 index cdc1b70a..00000000 --- a/frontend/app/components/auth/auth.test.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { h } from 'preact'; -import { IntlProvider } from 'react-intl'; -import { Provider } from 'react-redux'; - -import enMessages from 'locales/en.json'; -import stubStore from '__stubs__/store'; -import { StaticStore } from 'common/static-store'; - -import Auth from './auth'; -import { mount } from 'enzyme'; -import { Button } from '../button'; -import { StoreState } from 'store'; - -const initialStore = { - provider: { name: 'google' }, - theme: 'light', - comments: { - sort: '-score', - } as StoreState['comments'], -} as const; - -describe('', () => { - const createWrapper = (store?: Partial) => - mount( - - - - - - ); - it('should render login form with google and github provider', () => { - StaticStore.config.auth_providers = ['google', 'github']; - const element = createWrapper(); - - const providersButtons = element.find(Button); - - expect(element.text()).toEqual(expect.stringContaining('Login:')); - - expect(providersButtons.at(0).text()).toEqual('Google'); - expect(providersButtons.at(1).text()).toEqual('GitHub'); - }); - - describe('providers sorting', () => { - it('should do nothing if provider not found', () => { - StaticStore.config.auth_providers = ['google', 'github']; - const element = createWrapper({ - ...initialStore, - provider: { name: 'baidu' }, - }); - - const providerLinks = element.find(Button); - - expect(providerLinks.at(0).text()).toEqual('Google'); - expect(providerLinks.at(1).text()).toEqual('GitHub'); - }); - it('should place selected provider first', () => { - StaticStore.config.auth_providers = ['google', 'github']; - const element = createWrapper({ - ...initialStore, - provider: { name: 'github' }, - }); - - const providerLinks = element.find(Button); - - expect(providerLinks.at(0).text()).toEqual('GitHub'); - expect(providerLinks.at(1).text()).toEqual('Google'); - }); - }); -}); diff --git a/frontend/app/components/auth/auth.tsx b/frontend/app/components/auth/auth.tsx index 304f6824..f6148740 100644 --- a/frontend/app/components/auth/auth.tsx +++ b/frontend/app/components/auth/auth.tsx @@ -1,197 +1,277 @@ -import { h, Component, createRef } from 'preact'; -import { useCallback } from 'preact/hooks'; -import { IntlShape, FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { h, Fragment, FunctionComponent } from 'preact'; +import { useState } from 'preact/hooks'; +import { useIntl } from 'react-intl'; +import cn from 'classnames'; +import { useDispatch } from 'react-redux'; -import { AuthProvider, Theme, User } from 'common/types'; -import { PROVIDER_NAMES, IS_STORAGE_AVAILABLE } from 'common/constants'; -import { getHandleClickProps } from 'common/accessibility'; -import { Button } from 'components/button'; -import { Dropdown, DropdownItem } from 'components/dropdown'; +import { setUser } from 'store/user/actions'; +import { Input } from 'components/input'; +import TextareaAutosize from 'components/textarea-autosize'; -import debounce from 'utils/debounce'; -import { ProviderState } from 'store/provider/reducers'; -import { StaticStore } from 'common/static-store'; -import { useSelector, useDispatch } from 'react-redux'; -import { StoreState } from 'store'; -import useTheme from 'hooks/useTheme'; -import { logIn } from 'store/user/actions'; - -import { AnonymousLoginForm } from './__anonymous-login-form'; -import { EmailLoginFormConnected, EmailLoginFormRef } from './__email-login-form'; +import Button from './components/button'; +import OAuthProviders from './components/oauth'; +import messages from './auth.messsages'; +import { useDropdown } from './auth.hooks'; +import { getProviders, getTokenInvalidReason } from './auth.utils'; +import { emailSignin, verifyEmailSignin, anonymousSignin } from './auth.api'; import styles from './auth.module.css'; -interface Props { - intl: IntlShape; - theme: Theme; - onSignIn(provider: AuthProvider): any; // eslint-disable-line - user: User | null; - provider: ProviderState; -} - -interface State { - threshold: number; -} - -class Auth extends Component { - emailLoginRef = createRef(); - singInMessageAndSortWidth = 255; - - state = { - threshold: 3, - }; - - componentWillMount() { - this.resizeHandler(); - window.addEventListener('resize', this.resizeHandler); - } - - componentWillUnmount() { - window.removeEventListener('resize', this.resizeHandler); - } - resizeHandler = debounce(() => { - this.setState({ - threshold: Math.max(3, Math.round((window.innerWidth - this.singInMessageAndSortWidth) / 80)), - }); - }, 100); - - onEmailTitleClick = () => { - this.emailLoginRef.current && this.emailLoginRef.current.focus(); - }; - - onEmailSignIn = (token: string) => { - return this.props.onSignIn({ name: 'email', token }); - }; - - handleOAuthLogin = async (e: MouseEvent | KeyboardEvent) => { - const name = (e.target as HTMLButtonElement).dataset.provider! as AuthProvider['name']; - - this.props.onSignIn({ name } as AuthProvider); - }; - - handleAnonymousLoginFormSubmut = async (username: string) => { - this.props.onSignIn({ name: 'anonymous', username }); - }; - - renderOther = (providers: AuthProvider['name'][]) => { - const other = this.props.intl.formatMessage(authPanelMessages.otherProvider); - - return ( - - {providers.map((provider) => ( - {this.renderProvider(provider)} - ))} - - ); - }; - - renderProvider = (provider: AuthProvider['name']) => { - if (provider === 'anonymous') { - const anonymous = this.props.intl.formatMessage(authPanelMessages.anonymousProvider); - return ( - - - - - - ); - } - if (provider === 'email') { - return ( - - - - - - ); - } - - return ( - - ); - }; - - render({ intl }: Props, { threshold }: State) { - if (!IS_STORAGE_AVAILABLE) return null; - - const sortedProviders = ((providers): typeof providers => { - if (!this.props.provider.name) return providers; - const lastProviderIndex = providers.indexOf(this.props.provider.name as typeof providers[0]); - if (lastProviderIndex < 1) return providers; - return [ - this.props.provider.name as typeof providers[0], - ...providers.slice(0, lastProviderIndex), - ...providers.slice(lastProviderIndex + 1), - ]; - })(StaticStore.config.auth_providers); - - const isAboveThreshold = sortedProviders.length > threshold; - const or = intl.formatMessage(authPanelMessages.orProvider); - - return ( -
- {' '} - {!isAboveThreshold && - sortedProviders.map((provider, i) => { - const comma = i === 0 ? '' : i === sortedProviders.length - 1 ? ` ${or} ` : ', '; - - return ( - - {comma} - {this.renderProvider(provider)} - - ); - })} - {isAboveThreshold && - sortedProviders.slice(0, threshold - 1).map((provider, i) => { - const comma = i === 0 ? '' : ', '; - - return ( - - {comma} - {this.renderProvider(provider)} - - ); - })} - {isAboveThreshold && ( - - {` ${or} `} - {this.renderOther(sortedProviders.slice(threshold - 1))} - - )} -
- ); - } -} - -const authPanelMessages = defineMessages({ - otherProvider: { - id: 'authPanel.other-provider', - defaultMessage: 'Other', - }, - anonymousProvider: { - id: 'authPanel.anonymous-provider', - defaultMessage: 'Anonymous', - }, - orProvider: { - id: 'authPanel.or-provider', - defaultMessage: 'or', - }, -}); - -export default function AuthWrapper() { - const dispatch = useDispatch(); - const provider = useSelector((store) => store.provider); - const user = useSelector((store) => store.user); - const theme = useTheme(); +const Auth: FunctionComponent = () => { const intl = useIntl(); - const handleSignin = useCallback((provider: AuthProvider) => dispatch(logIn(provider)), [dispatch]); + const dispath = useDispatch(); + const [oauthProviders, formProviders] = getProviders(); - return ; -} + // UI State + const [isLoading, setLoading] = useState(false); + const [view, setView] = useState(formProviders[0]); + const [ref, isDropdownShowed, toggleDropdownState] = useDropdown(view === 'token'); + + // Errors + const [invalidReason, setInvalidReason] = useState(null); + + const handleClickSingIn = (evt: Event) => { + evt.preventDefault(); + toggleDropdownState(); + }; + + const handleDropdownClose = (evt: Event) => { + evt.preventDefault(); + setView(formProviders[0]); + toggleDropdownState(); + }; + + const handleProviderChange = (evt: Event) => { + const { value } = evt.currentTarget as HTMLInputElement; + + setInvalidReason(null); + setView(value as typeof formProviders[number]); + }; + + const handleSubmit = async (evt: Event) => { + const data = new FormData(evt.target as HTMLFormElement); + + evt.preventDefault(); + setLoading(true); + setInvalidReason(null); + + try { + switch (view) { + case 'anonymous': { + const username = data.get('username') as string; + const user = await anonymousSignin(username); + + dispath(setUser(user)); + break; + } + case 'email': { + const email = data.get('email') as string; + const username = data.get('username') as string; + + await emailSignin(email, username); + setView('token'); + break; + } + case 'token': { + const token = data.get('token') as string; + const invalidReason = getTokenInvalidReason(token); + + if (invalidReason) { + setInvalidReason(invalidReason); + } else { + const user = await verifyEmailSignin(token); + dispath(setUser(user)); + } + + break; + } + } + } catch (e) { + setInvalidReason(e.message || e.error); + } + + setLoading(false); + }; + + const handleShowEmailStep = (evt: Event) => { + evt.preventDefault(); + setView('email'); + }; + + const hasOAuthProviders = oauthProviders.length > 0; + const hasFormProviders = formProviders.length > 0; + const errorMessage = + invalidReason !== null && invalidReason in messages ? intl.formatMessage(messages[invalidReason]) : invalidReason; + const isTokenView = view === 'token'; + const submitButton = ( + + ); + + return ( +
+ + {isDropdownShowed && ( +
+
+ {isTokenView ? ( + <> +
+
+ +
+ +
+
+ +
+ + + ) : ( + <> + {hasOAuthProviders && ( + <> +
Use Social Network
+ + + )} + {hasOAuthProviders && hasFormProviders && ( +
+ )} + {hasFormProviders && ( + <> + {formProviders.length === 1 ? ( +
{formProviders[0]}
+ ) : ( +
+ {formProviders.map((p) => ( + + + + + ))} +
+ )} + +
+ +
+ {view === 'email' && ( +
+ +
+ )} + + {errorMessage &&
{errorMessage}
} + {submitButton} + + )} + + )} + +
+ )} +
+ ); +}; + +export default Auth; diff --git a/frontend/app/components/auth/auth.utils.ts b/frontend/app/components/auth/auth.utils.ts new file mode 100644 index 00000000..acdbb628 --- /dev/null +++ b/frontend/app/components/auth/auth.utils.ts @@ -0,0 +1,29 @@ +import { isJwtExpired } from 'utils/jwt'; +import { StaticStore } from 'common/static-store'; +import type { FormProvider, OAuthProvider } from 'common/types'; + +import { OAUTH_PROVIDERS } from './components/oauth.consts'; +import messages from './auth.messsages'; + +export function getProviders(): [OAuthProvider[], FormProvider[]] { + const oauthProviders: OAuthProvider[] = []; + const formProviders: FormProvider[] = []; + + StaticStore.config.auth_providers.forEach((p) => { + OAUTH_PROVIDERS.includes(p) ? oauthProviders.push(p as OAuthProvider) : formProviders.push(p as FormProvider); + }); + + return [oauthProviders, formProviders]; +} + +export function getTokenInvalidReason(token: string): null | keyof typeof messages { + try { + if (isJwtExpired(token)) { + return 'expiredToken'; + } + } catch (e) { + return 'invalidToken'; + } + + return null; +} diff --git a/frontend/app/components/auth/components/assets/dev.svg b/frontend/app/components/auth/components/assets/dev.svg new file mode 100644 index 00000000..d047cf8e --- /dev/null +++ b/frontend/app/components/auth/components/assets/dev.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/components/auth/components/assets/facebook.svg b/frontend/app/components/auth/components/assets/facebook.svg new file mode 100644 index 00000000..a2f84e55 --- /dev/null +++ b/frontend/app/components/auth/components/assets/facebook.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/components/auth/components/assets/github-dark.svg b/frontend/app/components/auth/components/assets/github-dark.svg new file mode 100644 index 00000000..e9c30de8 --- /dev/null +++ b/frontend/app/components/auth/components/assets/github-dark.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/components/auth/components/assets/github-light.svg b/frontend/app/components/auth/components/assets/github-light.svg new file mode 100644 index 00000000..7a69e60a --- /dev/null +++ b/frontend/app/components/auth/components/assets/github-light.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/components/auth/components/assets/google.svg b/frontend/app/components/auth/components/assets/google.svg new file mode 100644 index 00000000..bc3e5b9d --- /dev/null +++ b/frontend/app/components/auth/components/assets/google.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/components/auth/components/assets/microsoft.svg b/frontend/app/components/auth/components/assets/microsoft.svg new file mode 100644 index 00000000..145aa46d --- /dev/null +++ b/frontend/app/components/auth/components/assets/microsoft.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/components/auth/components/assets/twitter.svg b/frontend/app/components/auth/components/assets/twitter.svg new file mode 100644 index 00000000..21dbac48 --- /dev/null +++ b/frontend/app/components/auth/components/assets/twitter.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/components/auth/components/assets/yandex.svg b/frontend/app/components/auth/components/assets/yandex.svg new file mode 100644 index 00000000..5ec7ac22 --- /dev/null +++ b/frontend/app/components/auth/components/assets/yandex.svg @@ -0,0 +1 @@ + diff --git a/frontend/app/components/auth/components/button.module.css b/frontend/app/components/auth/components/button.module.css new file mode 100644 index 00000000..c03c24fe --- /dev/null +++ b/frontend/app/components/auth/components/button.module.css @@ -0,0 +1,84 @@ +.button { + position: relative; + display: inline-flex; + box-sizing: border-box; + width: 100%; + border: 1px solid; + border-color: transparent; + padding: 7px 12px; + margin: 0; + justify-content: center; + align-items: center; + border-radius: 4px; + font-family: inherit; + font-size: 16px; + font-weight: bold; + cursor: pointer; + white-space: nowrap; + background-color: rgb(var(--primary-color)); + color: rgb(var(--white-color)); + + &:hover { + background-color: rgb(var(--primary-brighter-color)); + } + + &:focus { + box-shadow: 0 0 0 2px rgba(var(--primary-color), 0.4); + outline: none; + } + + &:disabled { + opacity: 0.6; + cursor: default; + background-color: var(--color9); + } +} + +.suffix { + position: relative; + display: flex; + justify-content: center; + align-items: center; + margin-right: -12px; + margin-left: 12px; + padding: 0 10px; + + &::before { + position: absolute; + left: 0; + content: ''; + height: 36px; + border-left: 1px solid rgba(var(--white-color), 0.2); + } +} + +.selected:hover { + background-color: rgb(var(--primary-color)); +} + +.small { + height: 28px; + font-size: 12px; + text-transform: uppercase; +} + +.transparent { + background-color: rgba(var(--primary-color), 0.1); + color: rgb(var(--primary-color)); + + &:hover { + background-color: rgba(var(--primary-color), 0.2); + color: rgb(var(--primary-color)); + } +} + +:global(.dark) { + & .button { + border-color: rgba(var(--white-color), 0.1); + } + + & .transparent { + border-color: transparent; + color: inherit; + } +} diff --git a/frontend/app/components/auth/components/button.tsx b/frontend/app/components/auth/components/button.tsx new file mode 100644 index 00000000..2af70777 --- /dev/null +++ b/frontend/app/components/auth/components/button.tsx @@ -0,0 +1,25 @@ +import { h, FunctionComponent, JSX, VNode } from 'preact'; +import classnames from 'classnames/bind'; + +import styles from './button.module.css'; + +const cx = classnames.bind(styles); + +export type ButtonProps = Omit, 'size'> & { + size?: 'small'; + kind?: 'transparent'; + suffix?: VNode; + loading?: boolean; + selected?: boolean; +}; + +const Button: FunctionComponent = ({ children, size, kind, suffix, selected, className, ...props }) => { + return ( + + ); +}; + +export default Button; diff --git a/frontend/app/components/auth/components/oauth.api.ts b/frontend/app/components/auth/components/oauth.api.ts new file mode 100644 index 00000000..1ff34ff9 --- /dev/null +++ b/frontend/app/components/auth/components/oauth.api.ts @@ -0,0 +1,58 @@ +import { getUser } from 'common/api'; +import { User } from 'common/types'; + +/** + * Performs await of auth from oauth providers + */ +let subscribed = false; +let timeout: NodeJS.Timeout; +let authWindow: Window | null = null; + +/** + * Set waiting state and tries to revalidate `user` when oauth tab is closed + */ +export function oauthSignin(url: string): Promise { + authWindow = window.open(url); + + if (subscribed) { + return Promise.resolve(null); + } + + return new Promise((resolve, reject) => { + function unsubscribe() { + document.removeEventListener('visibilitychange', handleWindowVisibilityChange); + window.removeEventListener('focus', handleWindowVisibilityChange); + subscribed = false; + clearTimeout(timeout); + } + + async function handleWindowVisibilityChange() { + if (!document.hasFocus() || document.hidden || !authWindow?.closed) { + return; + } + + const user = await getUser(); + + clearTimeout(timeout); + + if (user === null) { + // Retry after 1 min if current attempt unsuccessful + timeout = setTimeout(() => { + handleWindowVisibilityChange(); + }, 60 * 1000); + + return null; + } + + resolve(user); + unsubscribe(); + } + + setTimeout(() => { + reject(); + }, 5 * 60 * 1000); + + document.addEventListener('visibilitychange', handleWindowVisibilityChange); + window.addEventListener('focus', handleWindowVisibilityChange); + }); +} diff --git a/frontend/app/components/auth/components/oauth.consts.ts b/frontend/app/components/auth/components/oauth.consts.ts new file mode 100644 index 00000000..5631125a --- /dev/null +++ b/frontend/app/components/auth/components/oauth.consts.ts @@ -0,0 +1,17 @@ +export const OAUTH_DATA = { + facebook: require('./assets/facebook.svg').default as string, + twitter: require('./assets/twitter.svg').default as string, + google: require('./assets/google.svg').default as string, + microsoft: require('./assets/microsoft.svg').default as string, + yandex: require('./assets/yandex.svg').default as string, + dev: require('./assets/dev.svg').default as string, + github: { + name: 'GitHub', + icons: { + light: require('./assets/github-light.svg').default as string, + dark: require('./assets/github-dark.svg').default as string, + }, + }, +} as const; + +export const OAUTH_PROVIDERS = Object.keys(OAUTH_DATA); diff --git a/frontend/app/components/auth/components/oauth.module.css b/frontend/app/components/auth/components/oauth.module.css new file mode 100644 index 00000000..9a0ce930 --- /dev/null +++ b/frontend/app/components/auth/components/oauth.module.css @@ -0,0 +1,71 @@ +.root { + display: flex; + align-items: center; + justify-content: center; + list-style: none; + margin: 0; + padding: 0; +} + +.item { + position: relative; +} + +.item + .item { + margin-left: 12px; +} + +.button { + display: flex; + width: 38px; + height: 38px; + cursor: pointer; + align-items: center; + justify-content: center; + border: 1px solid var(--line-color); + border-radius: 50%; + + &::after { + display: inline-block; + } + + &:hover { + transform: scale(1.05); + border-color: var(--line-brighter-color); + } + + &:active { + transform: scale(1); + } + + &:focus { + border-color: var(--line-brighter-color); + box-shadow: 0 0 0 2px var(--line-color); + } +} + +.name, +.full { + border-radius: 6px; + text-decoration: none; + width: auto; + padding: 0 12px; + + &:hover { + transform: scale(1); + } + + &:active { + transform: scale(0.95); + } + + &::after { + display: inline-block; + margin-left: 8px; + content: attr(data-provider-name); + } +} + +.full::after { + content: attr(title); +} diff --git a/frontend/app/components/auth/components/oauth.spec.tsx b/frontend/app/components/auth/components/oauth.spec.tsx new file mode 100644 index 00000000..7a4347c6 --- /dev/null +++ b/frontend/app/components/auth/components/oauth.spec.tsx @@ -0,0 +1,63 @@ +import { h } from 'preact'; +import { fireEvent, render, waitFor } from '@testing-library/preact'; + +import * as userActions from 'store/user/actions'; + +import OAuth from './oauth'; +import * as api from './oauth.api'; +import { User } from 'common/types'; + +jest.mock('react-redux', () => ({ + useDispatch: () => jest.fn(), +})); + +jest.mock('hooks/useTheme', () => () => 'light'); + +describe('', () => { + it('should have permanent class name', () => { + const { container } = render(); + + expect(container.querySelector('ul')?.getAttribute('class')).toContain('oauth'); + expect(container.querySelector('li')?.getAttribute('class')).toContain('oauth-item'); + expect(container.querySelector('a')?.getAttribute('class')).toContain('oauth-button'); + expect(container.querySelector('img')?.getAttribute('class')).toContain('oauth-icon'); + }); + + it('should have rigth `href`', () => { + const { container } = render(); + + expect(container.querySelector('a')?.getAttribute('href')).toBe( + '/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark' + ); + }); + + it('should not set user if unauthorized', async () => { + const setUser = jest.spyOn(userActions, 'setUser').mockImplementation(jest.fn()); + const oauthSignin = jest.spyOn(api, 'oauthSignin').mockImplementation(async () => null); + const { container } = render(); + + fireEvent.click(container.querySelector('a')!); + + await waitFor(() => + expect(oauthSignin).toBeCalledWith( + 'http://localhost/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark' + ) + ); + expect(setUser).toBeCalledTimes(0); + }); + + it('should set user if authorized', async () => { + const setUser = jest.spyOn(userActions, 'setUser').mockImplementation(jest.fn()); + const oauthSignin = jest.spyOn(api, 'oauthSignin').mockImplementation(async () => ({} as User)); + const { container } = render(); + + fireEvent.click(container.querySelector('a')!); + + await waitFor(() => + expect(oauthSignin).toBeCalledWith( + 'http://localhost/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark' + ) + ); + expect(setUser).toBeCalledWith({}); + }); +}); diff --git a/frontend/app/components/auth/components/oauth.tsx b/frontend/app/components/auth/components/oauth.tsx new file mode 100644 index 00000000..6c2766dc --- /dev/null +++ b/frontend/app/components/auth/components/oauth.tsx @@ -0,0 +1,66 @@ +import { h, FunctionComponent, JSX } from 'preact'; +import { useDispatch } from 'react-redux'; +import cn from 'classnames'; +import { useIntl } from 'react-intl'; + +import { siteId } from 'common/settings'; +import type { OAuthProvider } from 'common/types'; +import useTheme from 'hooks/useTheme'; +import { setUser } from 'store/user/actions'; + +import messages from 'components/auth/auth.messsages'; + +import { getButtonVariant, getProviderData } from './oauth.utils'; +import { oauthSignin } from './oauth.api'; +import styles from './oauth.module.css'; + +export type OAuthProvidersProps = { + providers: OAuthProvider[]; +}; + +const location = encodeURIComponent(`${window.location.origin}${window.location.pathname}?selfClose`); + +const OAuthProviders: FunctionComponent = ({ providers }) => { + const intl = useIntl(); + const dispath = useDispatch(); + const theme = useTheme(); + const buttonVariant = getButtonVariant(providers.length); + const handleOathClick: JSX.GenericEventHandler = async (evt) => { + const { href } = evt.currentTarget as HTMLAnchorElement; + + evt.preventDefault(); + const user = await oauthSignin(href); + + if (user === null) { + return; + } + + dispath(setUser(user)); + }; + + return ( +
    + {providers.map((p) => { + const { name, icon } = getProviderData(p, theme); + + return ( +
  • + + + +
  • + ); + })} +
+ ); +}; + +export default OAuthProviders; diff --git a/frontend/app/components/auth/components/oauth.utils.ts b/frontend/app/components/auth/components/oauth.utils.ts new file mode 100644 index 00000000..8dd14326 --- /dev/null +++ b/frontend/app/components/auth/components/oauth.utils.ts @@ -0,0 +1,29 @@ +import { OAuthProvider, Theme } from 'common/types'; +import capitalizeFirstLetter from 'utils/capitalize-first-letter'; + +import { OAUTH_DATA } from './oauth.consts'; + +export function getButtonVariant(num: number) { + if (num === 2) { + return 'name'; + } + + if (num === 1) { + return 'full'; + } + + return 'icon'; +} + +export function getProviderData(provider: OAuthProvider, theme: Theme) { + const data = OAUTH_DATA[provider]; + + if (typeof data !== 'string') { + return { + name: data.name, + icon: data.icons[theme], + }; + } + + return { name: capitalizeFirstLetter(provider), icon: data }; +} diff --git a/frontend/app/components/auth/validateUserName.test.ts b/frontend/app/components/auth/validateUserName.test.ts deleted file mode 100644 index 80b5994d..00000000 --- a/frontend/app/components/auth/validateUserName.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { validateUserName } from './validateUserName'; - -describe('validate user name', () => { - it('should allow good name', () => { - expect(validateUserName('Раз_Два Три_34567')).toEqual(true); - }); - it('should not allow bad name', () => { - expect(validateUserName('**blah123')).toEqual(false); - }); - it('should not allow only spaces', () => { - expect(validateUserName(' ')).toEqual(false); - }); -}); diff --git a/frontend/app/components/auth/validateUserName.ts b/frontend/app/components/auth/validateUserName.ts deleted file mode 100644 index 988a4785..00000000 --- a/frontend/app/components/auth/validateUserName.ts +++ /dev/null @@ -1,4 +0,0 @@ -const userNameRegex = /^[\p{L}\d_ ]+$/u; -export function validateUserName(userName: string) { - return userNameRegex.test(userName.trim()); -} diff --git a/frontend/app/components/button/_size/_large/button_size_large.css b/frontend/app/components/button/_size/_large/button_size_large.css index f721bd3c..0a7d1c8e 100644 --- a/frontend/app/components/button/_size/_large/button_size_large.css +++ b/frontend/app/components/button/_size/_large/button_size_large.css @@ -1,5 +1,5 @@ .button_size_large { - height: 2rem; + height: 36px; padding: 0 12px; font-size: 16px; } diff --git a/frontend/app/components/button/button.css b/frontend/app/components/button/button.css index e4dba6a6..429ac9fd 100644 --- a/frontend/app/components/button/button.css +++ b/frontend/app/components/button/button.css @@ -3,7 +3,7 @@ border: 0; padding: 0; margin: 0; - border-radius: 2px; + border-radius: 4px; font-family: inherit; font-size: inherit; cursor: pointer; diff --git a/frontend/app/components/comment-form/__button/_type/_preview/comment-form__button_type_preview.css b/frontend/app/components/comment-form/__button/_type/_preview/comment-form__button_type_preview.css index 38c2c5db..a18ce062 100644 --- a/frontend/app/components/comment-form/__button/_type/_preview/comment-form__button_type_preview.css +++ b/frontend/app/components/comment-form/__button/_type/_preview/comment-form__button_type_preview.css @@ -2,6 +2,6 @@ &:hover, &:focus { box-shadow: inset 0 0 0 2px var(--color9); - color: var(--color17); + color: var(--color15); } } diff --git a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx index ba9491e2..1ee5cf66 100644 --- a/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx +++ b/frontend/app/components/comment-form/__subscribe-by-email/comment-form__subscribe-by-email.tsx @@ -1,5 +1,5 @@ import { h, FunctionComponent, Fragment } from 'preact'; -import { useState, useCallback, useEffect, useRef, PropRef } from 'preact/hooks'; +import { useState, useCallback, useRef } from 'preact/hooks'; import { useSelector, useDispatch } from 'react-redux'; import b from 'bem-react-helper'; import { useIntl, defineMessages, IntlShape, FormattedMessage } from 'react-intl'; @@ -21,7 +21,7 @@ import TextareaAutosize from 'components/textarea-autosize'; import { isUserAnonymous } from 'utils/isUserAnonymous'; import { isJwtExpired } from 'utils/jwt'; -const emailRegex = /[^@]+@[^.]+\..+/; +const emailRegexp = /[^@]+@[^.]+\..+/; enum Step { Email, @@ -33,6 +33,14 @@ enum Step { } const messages = defineMessages({ + token: { + id: 'token', + defaultMessage: 'Token', + }, + expiredToken: { + id: 'token.expired', + defaultMessage: 'Token is expired', + }, haveSubscribed: { id: 'subscribeByEmail.have-been-subscribed', defaultMessage: 'You have been subscribed on updates by email', @@ -57,14 +65,6 @@ const messages = defineMessages({ id: 'subscribeByEmail.only-registered-users', defaultMessage: 'Available only for registered users', }, - expiredToken: { - id: 'subscribeByEmail.expired-token', - defaultMessage: 'Expired token', - }, - token: { - id: 'subscribeByEmail.token', - defaultMessage: 'Token', - }, email: { id: 'subscribeByEmail.email', defaultMessage: 'Email', @@ -75,16 +75,15 @@ const renderEmailPart = ( loading: boolean, intl: IntlShape, emailAddress: string, - handleChangeEmail: (e: Event) => void, - emailAddressRef: PropRef + handleChangeEmail: (e: Event) => void ) => ( <>
{ const subscribed = useSelector(({ user }) => user === null ? false : Boolean(user.email_subscription) ); - const emailAddressRef = useRef(); const previousStep = useRef(null); const [step, setStep] = useState(subscribed ? Step.Subscribed : Step.Email); @@ -199,7 +197,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => { [sendForm] ); - const isValidEmailAddress = emailRegex.test(emailAddress); + const isValidEmailAddress = emailRegexp.test(emailAddress); const setEmailStep = useCallback(async () => { await sleep(0); @@ -221,12 +219,6 @@ export const SubscribeByEmailForm: FunctionComponent = () => { } }, [setLoading, setStep, setError, dispatch, intl]); - useEffect(() => { - if (emailAddressRef.current) { - emailAddressRef.current.focus(); - } - }, []); - /** * It needs for dropdown closing by click on button * More info below @@ -288,7 +280,7 @@ export const SubscribeByEmailForm: FunctionComponent = () => { return (
- {step === Step.Email && renderEmailPart(loading, intl, emailAddress, handleChangeEmail, emailAddressRef)} + {step === Step.Email && renderEmailPart(loading, intl, emailAddress, handleChangeEmail)} {step === Step.Token && renderTokenPart(loading, intl, token, handleChangeToken, setEmailStep)} {error !== null && (
diff --git a/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx index 92eaf09b..0e17e76c 100644 --- a/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx +++ b/frontend/app/components/comment-form/__subscribe-by-rss/comment-form__subscribe-by-rss.test.tsx @@ -7,17 +7,6 @@ jest.mock('react-redux', () => ({ useSelector: jest.fn((fn) => fn({ theme: 'light' })), })); -jest.mock('react-intl', () => { - const reactIntl = jest.requireActual('react-intl'); - const messages = require('locales/en.json'); - const intlProvider = new reactIntl.IntlProvider({ locale: 'en', messages }, {}); - - return { - ...reactIntl, - useIntl: () => intlProvider.state.intl, - }; -}); - describe('', () => { it('should be render links in dropdown', () => { const wrapper = shallow(); diff --git a/frontend/app/components/comment/__control/comment__control.css b/frontend/app/components/comment/__control/comment__control.css index 117446b4..db1e4cfe 100644 --- a/frontend/app/components/comment/__control/comment__control.css +++ b/frontend/app/components/comment/__control/comment__control.css @@ -1,6 +1,6 @@ .comment__control { margin-right: 8px; - color: var(--color39); + color: var(--color33); &:last-child { margin-right: 0; diff --git a/frontend/app/components/input/index.ts b/frontend/app/components/input/index.ts index 01d618c0..188cbaa0 100644 --- a/frontend/app/components/input/index.ts +++ b/frontend/app/components/input/index.ts @@ -1,3 +1,2 @@ -import './input.css'; - export { Input } from './input'; +export type { InputProps } from './input'; diff --git a/frontend/app/components/input/input.css b/frontend/app/components/input/input.css deleted file mode 100644 index 2e798f55..00000000 --- a/frontend/app/components/input/input.css +++ /dev/null @@ -1,13 +0,0 @@ -.input { - font-size: 16px; - font-family: inherit; - padding: 4px 8px; - border: 1px solid var(--color31); - margin: 0; - - &:focus { - box-shadow: 0 0 0 2px var(--color47); - border-color: var(--color15); - outline: none; - } -} diff --git a/frontend/app/components/input/input.module.css b/frontend/app/components/input/input.module.css new file mode 100644 index 00000000..ea3f88d4 --- /dev/null +++ b/frontend/app/components/input/input.module.css @@ -0,0 +1,86 @@ +/* stylelint-disable no-descending-specificity */ +.input { + width: 100%; + box-sizing: border-box; + border: 1px solid var(--line-color); + border-radius: 4px; + margin: 0; + padding: 0 8px; + height: 36px; + font-size: 16px; + font-family: inherit; + background-color: rgb(var(--white-color)); + color: var(--color7); + + &:hover { + border-color: var(--line-brighter-color); + } + + &:focus { + box-shadow: 0 0 0 2px var(--color47); + border-color: var(--color15); + outline: none; + } + + &:disabled { + background-color: var(--color21); + border-color: var(--line-color); + } + + &::placeholder { + color: var(--color11); + -webkit-text-fill-color: var(--color11); + } +} + +.input:-webkit-autofill { + box-shadow: 0 0 0 1000px rgb(var(--white-color)) inset; + -webkit-text-fill-color: var(--color7); + + &:focus { + box-shadow: 0 0 0 1000px rgb(var(--white-color)) inset, 0 0 0 2px var(--color47); + } + + &::placeholder { + -webkit-text-fill-color: var(--color11); + } +} + +:global(.dark) { + & .input { + background-color: var(--color22); + color: rgba(var(--white-color), 0.8); + + &:focus { + border-color: var(--color15); + box-shadow: 0 0 0 2px var(--color47); + } + + &::placeholder { + color: rgba(var(--white-color), 0.2); + } + } + + & .input:-webkit-autofill { + box-shadow: 0 0 0 1000px var(--color22) inset; + -webkit-text-fill-color: rgba(var(--white-color), 0.8); + + &:focus { + box-shadow: 0 0 0 1000px var(--color22) inset, 0 0 0 2px var(--color47); + -webkit-text-fill-color: rgba(var(--white-color), 0.8); + } + + &::placeholder { + -webkit-text-fill-color: rgba(var(--white-color), 0.4); + } + } +} + +.invalid { + &, + &:hover, + &:focus { + border-color: var(--error-color); + box-shadow: 0 0 0 2px var(--error-background); + } +} diff --git a/frontend/app/components/input/input.tsx b/frontend/app/components/input/input.tsx index 5e63fc87..84cf699f 100644 --- a/frontend/app/components/input/input.tsx +++ b/frontend/app/components/input/input.tsx @@ -1,21 +1,18 @@ import { h, JSX } from 'preact'; -import { forwardRef } from 'preact/compat'; -import b, { Mods, Mix } from 'bem-react-helper'; +import classnames from 'classnames/bind'; -import type { Theme } from 'common/types'; +import styles from './input.module.css'; + +const cx = classnames.bind(styles); export type InputProps = { - kind?: 'primary' | 'secondary'; - theme?: Theme; - mods?: Mods; - mix?: Mix; + invalid?: boolean; type?: string; -} & Omit; + className?: string; +} & JSX.HTMLAttributes; -export const Input = forwardRef( - ({ children, theme, mods, mix, type = 'text', ...props }, ref) => ( - - {children} - - ) +export const Input = ({ children, className, type = 'text', invalid, ...props }: InputProps) => ( + + {children} + ); diff --git a/frontend/app/components/root/root.tsx b/frontend/app/components/root/root.tsx index 4f79f430..d2234462 100644 --- a/frontend/app/components/root/root.tsx +++ b/frontend/app/components/root/root.tsx @@ -2,8 +2,10 @@ import { h, Component, FunctionComponent, Fragment } from 'preact'; import { useSelector } from 'react-redux'; import b from 'bem-react-helper'; import { IntlShape, useIntl, FormattedMessage, defineMessages } from 'react-intl'; +import classnames from 'classnames'; -import type { AuthProvider, Sorting } from 'common/types'; +import type { Sorting } from 'common/types'; +import type { StoreState } from 'store'; import { COMMENT_NODE_CLASSNAME_PREFIX, MAX_SHOWN_ROOT_COMMENTS, @@ -14,18 +16,16 @@ import { import { maxShownComments, url } from 'common/settings'; import { StaticStore } from 'common/static-store'; -import { StoreState } from 'store'; import { + setUser, fetchUser, - logout, - logIn, blockUser, unblockUser, fetchBlockedUsers, hideUser, unhideUser, } from 'store/user/actions'; -import { fetchComments, updateSorting, addComment, updateComment } from 'store/comments/actions'; +import { fetchComments, updateSorting, addComment, updateComment, unsetCommentMode } from 'store/comments/actions'; import { setCommentsReadOnlyState } from 'store/post-info/actions'; import { setTheme } from 'store/theme/actions'; @@ -42,6 +42,7 @@ import { bindActions } from 'utils/actionBinder'; import postMessage from 'utils/postMessage'; import { useActions } from 'hooks/useAction'; import { setCollapse } from 'store/thread/actions'; +import { logout } from 'components/auth/auth.api'; const mapStateToProps = (state: StoreState) => ({ sort: state.comments.sort, @@ -68,10 +69,9 @@ const mapStateToProps = (state: StoreState) => ({ const boundActions = bindActions({ updateSorting, fetchComments, + setUser, fetchUser, fetchBlockedUsers, - logIn, - logOut: logout, setTheme, setCommentsReadOnlyState, blockUser, @@ -81,6 +81,7 @@ const boundActions = bindActions({ addComment, updateComment, setCollapse, + unsetCommentMode, }); type Props = ReturnType & typeof boundActions & { intl: IntlShape }; @@ -144,16 +145,10 @@ export class Root extends Component { await this.props.updateSorting(sort); }; - logIn = async (provider: AuthProvider) => { - const user = await this.props.logIn(provider); - - await this.props.fetchComments(); - - return user; - }; - - logOut = async () => { - await this.props.logOut(); + logout = async () => { + await logout(); + this.props.setUser(); + this.props.unsetCommentMode(); localStorage.removeItem(LS_EMAIL_KEY); await this.props.fetchComments(); }; @@ -183,14 +178,16 @@ export class Root extends Component { }; onMessage(event: { data: string | object }) { + if (!event.data) { + return; + } + try { const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; if (data.theme && THEMES.includes(data.theme)) { this.props.setTheme(data.theme); } - } catch (e) { - console.error(e); // eslint-disable-line no-console - } + } catch (e) {} } onBlockedUsersShow = async () => { @@ -221,18 +218,13 @@ export class Root extends Component { }); }; - /** - * Defines whether current client is logged in via `Anonymous provider` - */ - isAnonymous = () => isUserAnonymous(this.props.user); - render(props: Props, { isUserLoading, commentsShown, isSettingsVisible }: State) { if (isUserLoading) { return ; } const isCommentsDisabled = props.info.read_only!; - const imageUploadHandler = this.isAnonymous() ? undefined : this.props.uploadImage; + const imageUploadHandler = isUserAnonymous(this.props.user) ? undefined : this.props.uploadImage; return ( @@ -242,8 +234,7 @@ export class Root extends Component { onSortChange={this.changeSort} isCommentsDisabled={isCommentsDisabled} postInfo={this.props.info} - onSignIn={this.logIn} - onSignOut={this.logOut} + onSignOut={this.logout} onBlockedUsersShow={this.onBlockedUsersShow} onBlockedUsersHide={this.onBlockedUsersHide} onCommentsChangeReadOnlyMode={this.props.setCommentsReadOnlyState} @@ -348,7 +339,7 @@ export const ConnectedRoot: FunctionComponent = () => { const intl = useIntl(); return ( -
+