From b97b1f94622e293a6303906215e475474ac113f3 Mon Sep 17 00:00:00 2001 From: Pavel Mineev Date: Mon, 7 Feb 2022 22:14:42 -0600 Subject: [PATCH] telegram adjustments and tests - get rid of redux store in favor of local state - add tests for telegram happy path - lift auth handling on Auth level --- frontend/app/common/api.ts | 4 +- frontend/app/common/types.ts | 5 - frontend/app/components/auth/auth.api.ts | 64 +++++- .../app/components/auth/auth.messsages.ts | 2 +- frontend/app/components/auth/auth.spec.tsx | 197 ++++++++++++------ frontend/app/components/auth/auth.tsx | 123 ++++++----- frontend/app/components/auth/auth.utils.ts | 39 ++++ .../components/auth/components/oauth.api.ts | 58 ------ .../components/auth/components/oauth.spec.tsx | 39 ---- .../app/components/auth/components/oauth.tsx | 60 ++---- frontend/app/store/user/actions.ts | 11 +- frontend/app/store/user/reducers.ts | 17 +- frontend/app/store/user/types.ts | 9 +- 13 files changed, 323 insertions(+), 305 deletions(-) delete mode 100644 frontend/app/components/auth/components/oauth.api.ts diff --git a/frontend/app/common/api.ts b/frontend/app/common/api.ts index 8bbf0886..5722df53 100644 --- a/frontend/app/common/api.ts +++ b/frontend/app/common/api.ts @@ -47,7 +47,9 @@ export const removeMyComment = (id: Comment['id']): Promise => export const getPreview = (text: string): Promise => apiFetcher.post('/preview', {}, { text }); -export const getUser = (): Promise => apiFetcher.get('/user').catch(() => null); +export function getUser(): Promise { + return apiFetcher.get('/user').catch(() => null); +} export const uploadImage = (image: File): Promise => { const data = new FormData(); diff --git a/frontend/app/common/types.ts b/frontend/app/common/types.ts index bb5a9c52..46a67d8a 100644 --- a/frontend/app/common/types.ts +++ b/frontend/app/common/types.ts @@ -167,8 +167,3 @@ export interface ApiError { /** in-depth explanation */ error: string; } - -export interface TelegramParams { - bot: string; - token: string; -} diff --git a/frontend/app/components/auth/auth.api.ts b/frontend/app/components/auth/auth.api.ts index b7decf72..972fbe59 100644 --- a/frontend/app/components/auth/auth.api.ts +++ b/frontend/app/components/auth/auth.api.ts @@ -1,7 +1,8 @@ -import type { User, TelegramParams } from 'common/types'; +import type { User } from 'common/types'; import { authFetcher } from 'common/fetcher'; import { siteId } from 'common/settings'; +import { getUser } from 'common/api'; const EMAIL_SIGNIN_ENDPOINT = '/email/login'; const TELEGRAM_SIGNIN_ENDPOINT = '/telegram/login'; @@ -24,10 +25,69 @@ export function verifyEmailSignin(token: string): Promise { return authFetcher.get(EMAIL_SIGNIN_ENDPOINT, { token }); } +/** + * 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); + }); +} + /** * First step of two of `telegram` authorization */ -export function getTelegramSigninParams(): Promise { +export function getTelegramSigninParams(): Promise<{ + bot: string; + token: string; +}> { return authFetcher.get(TELEGRAM_SIGNIN_ENDPOINT); } diff --git a/frontend/app/components/auth/auth.messsages.ts b/frontend/app/components/auth/auth.messsages.ts index 263a726c..f56536f5 100644 --- a/frontend/app/components/auth/auth.messsages.ts +++ b/frontend/app/components/auth/auth.messsages.ts @@ -1,6 +1,6 @@ import { defineMessages } from 'react-intl'; -export const messages = defineMessages({ +export const messages = defineMessages({ signin: { id: 'auth.signin', defaultMessage: 'Sign In', diff --git a/frontend/app/components/auth/auth.spec.tsx b/frontend/app/components/auth/auth.spec.tsx index 68cd36ee..eed4e987 100644 --- a/frontend/app/components/auth/auth.spec.tsx +++ b/frontend/app/components/auth/auth.spec.tsx @@ -1,19 +1,19 @@ import '@testing-library/jest-dom'; import { h } from 'preact'; -import { fireEvent, waitFor } from '@testing-library/preact'; +import { fireEvent, waitFor, screen } from '@testing-library/preact'; import { render } from 'tests/utils'; import { OAuthProvider, User } from 'common/types'; import { StaticStore } from 'common/static-store'; +import { BASE_URL } from 'common/constants.config'; +import * as userActions from 'store/user/actions'; import { Auth } from './auth'; import * as utils from './auth.utils'; import * as api from './auth.api'; import { getProviderData } from './components/oauth.utils'; -jest.mock('hooks/useTheme', () => ({ - useTheme: () => 'light', -})); +window.open = jest.fn(); describe('', () => { let defaultProviders = StaticStore.config.auth_providers; @@ -31,23 +31,23 @@ describe('', () => { }); it('should close dropdown by click on button', () => { - const { container, getByText } = render(); + const { container } = render(); expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument(); - fireEvent.click(getByText('Sign In')); + fireEvent.click(screen.getByText('Sign In')); expect(container.querySelector('.auth-dropdown')).toBeInTheDocument(); - fireEvent.click(getByText('Sign In')); + fireEvent.click(screen.getByText('Sign In')); expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument(); }); it('should close dropdown by click outside of it', () => { - const { container, getByText } = render(); + const { container } = render(); expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument(); - fireEvent.click(getByText('Sign In')); + fireEvent.click(screen.getByText('Sign In')); expect(container.querySelector('.auth-dropdown')).toBeInTheDocument(); fireEvent.click(document); @@ -55,11 +55,11 @@ describe('', () => { }); it('should close dropdown by message from parent', async () => { - const { container, getByText } = render(); + const { container } = render(); expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument(); - fireEvent.click(getByText('Sign In')); + fireEvent.click(screen.getByText('Sign In')); expect(container.querySelector('.auth-dropdown')).toBeInTheDocument(); window.postMessage('{"clickOutside": true}', '*'); @@ -77,63 +77,63 @@ describe('', () => { ] as [OAuthProvider[]][])('should renders with %j providers', async (providers) => { StaticStore.config.auth_providers = providers; - const { container, getByText, getByTitle, queryByPlaceholderText, queryByText } = render(); + const { container } = render(); expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument(); - expect(getByText('Sign In')).toHaveClass('auth-button'); - fireEvent.click(getByText('Sign In')); + expect(screen.getByText('Sign In')).toHaveClass('auth-button'); + fireEvent.click(screen.getByText('Sign In')); expect(container.querySelector('.auth-dropdown')).toBeInTheDocument(); providers.forEach((p) => { const { name } = getProviderData(p, 'light'); - expect(getByTitle(`Sign In with ${name}`)).toBeInTheDocument(); + expect(screen.getByTitle(`Sign In with ${name}`)).toBeInTheDocument(); }); - expect(queryByPlaceholderText('Username')).not.toBeInTheDocument(); - expect(queryByText('Submit')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('Username')).not.toBeInTheDocument(); + expect(screen.queryByText('Submit')).not.toBeInTheDocument(); }); it('should render email provider', () => { StaticStore.config.auth_providers = ['email']; - const { getByText, getByPlaceholderText } = render(); + render(); - fireEvent.click(getByText('Sign In')); - expect(getByText('email')).toHaveClass('auth-form-title'); - expect(getByPlaceholderText('Username')).toHaveClass('auth-input-username'); - expect(getByPlaceholderText('Email Address')).toHaveClass('auth-input-email'); - expect(getByText('Submit')).toHaveClass('auth-submit'); + fireEvent.click(screen.getByText('Sign In')); + expect(screen.getByText('email')).toHaveClass('auth-form-title'); + expect(screen.getByPlaceholderText('Username')).toHaveClass('auth-input-username'); + expect(screen.getByPlaceholderText('Email Address')).toHaveClass('auth-input-email'); + expect(screen.getByText('Submit')).toHaveClass('auth-submit'); }); it('should render anonymous provider', () => { StaticStore.config.auth_providers = ['anonymous']; - const { getByText, getByPlaceholderText } = render(); + render(); - fireEvent.click(getByText('Sign In')); - expect(getByText('anonymous')).toHaveClass('auth-form-title'); - expect(getByPlaceholderText('Username')).toHaveClass('auth-input-username'); - expect(getByText('Submit')).toHaveClass('auth-submit'); + fireEvent.click(screen.getByText('Sign In')); + expect(screen.getByText('anonymous')).toHaveClass('auth-form-title'); + expect(screen.getByPlaceholderText('Username')).toHaveClass('auth-input-username'); + expect(screen.getByText('Submit')).toHaveClass('auth-submit'); }); it('should render tabs with two form providers', () => { StaticStore.config.auth_providers = ['email', 'anonymous']; - const { getByText, getByLabelText, getByPlaceholderText, getByDisplayValue } = render(); + render(); - fireEvent.click(getByText('Sign In')); - expect(getByDisplayValue('email')).toHaveAttribute('id', 'form-provider-email'); - expect(getByText('email')).toHaveAttribute('for', 'form-provider-email'); - expect(getByText('email')).toHaveClass('auth-tabs-item'); - expect(getByDisplayValue('anonymous')).toHaveAttribute('id', 'form-provider-anonymous'); - expect(getByText('anonym')).toHaveAttribute('for', 'form-provider-anonymous'); - expect(getByText('anonym')).toHaveClass('auth-tabs-item'); - expect(getByPlaceholderText('Username')).toHaveClass('auth-input-username'); - expect(getByText('Submit')).toHaveClass('auth-submit'); + fireEvent.click(screen.getByText('Sign In')); + expect(screen.getByDisplayValue('email')).toHaveAttribute('id', 'form-provider-email'); + expect(screen.getByText('email')).toHaveAttribute('for', 'form-provider-email'); + expect(screen.getByText('email')).toHaveClass('auth-tabs-item'); + expect(screen.getByDisplayValue('anonymous')).toHaveAttribute('id', 'form-provider-anonymous'); + expect(screen.getByText('anonym')).toHaveAttribute('for', 'form-provider-anonymous'); + expect(screen.getByText('anonym')).toHaveClass('auth-tabs-item'); + expect(screen.getByPlaceholderText('Username')).toHaveClass('auth-input-username'); + expect(screen.getByText('Submit')).toHaveClass('auth-submit'); - fireEvent.click(getByLabelText('email')); - expect(getByPlaceholderText('Username')).toHaveClass('auth-input-username'); - expect(getByPlaceholderText('Email Address')).toHaveClass('auth-input-email'); - expect(getByText('Submit')).toHaveClass('auth-submit'); + fireEvent.click(screen.getByLabelText('email')); + expect(screen.getByPlaceholderText('Username')).toHaveClass('auth-input-username'); + expect(screen.getByPlaceholderText('Email Address')).toHaveClass('auth-input-email'); + expect(screen.getByText('Submit')).toHaveClass('auth-submit'); }); it('should send email and then verify forms', async () => { @@ -142,28 +142,28 @@ describe('', () => { jest.spyOn(api, 'verifyEmailSignin').mockImplementationOnce(async () => ({} as User)); jest.spyOn(utils, 'getTokenInvalidReason').mockImplementationOnce(() => null); - const { getByText, getByPlaceholderText, getByTitle, getByRole } = render(); + render(); - fireEvent.click(getByText('Sign In')); - fireEvent.change(getByPlaceholderText('Username'), { target: { value: 'username' } }); - fireEvent.change(getByPlaceholderText('Email Address'), { + fireEvent.click(screen.getByText('Sign In')); + fireEvent.change(screen.getByPlaceholderText('Username'), { target: { value: 'username' } }); + fireEvent.change(screen.getByPlaceholderText('Email Address'), { target: { value: 'email@email.com' }, }); - fireEvent.click(getByText('Submit')); + fireEvent.click(screen.getByText('Submit')); - expect(getByRole('presentation')).toHaveClass('spinner'); + expect(screen.getByRole('presentation')).toHaveClass('spinner'); await waitFor(() => expect(api.emailSignin).toBeCalled()); expect(api.emailSignin).toBeCalledWith('email@email.com', 'username'); - expect(getByText('Back')).toHaveClass('auth-back-button'); - expect(getByTitle('Close sign-in dropdown')).toHaveClass('auth-close-button'); - expect(getByPlaceholderText('Token')).toHaveClass('auth-token-textarea'); + expect(screen.getByText('Back')).toHaveClass('auth-back-button'); + expect(screen.getByTitle('Close sign-in dropdown')).toHaveClass('auth-close-button'); + expect(screen.getByPlaceholderText('Token')).toHaveClass('auth-token-textarea'); - fireEvent.change(getByPlaceholderText('Token'), { + fireEvent.change(screen.getByPlaceholderText('Token'), { target: { value: 'token' }, }); - fireEvent.click(getByText('Submit')); + fireEvent.click(screen.getByText('Submit')); await waitFor(() => expect(api.verifyEmailSignin).toBeCalled()); expect(api.verifyEmailSignin).toBeCalledWith('token'); @@ -201,13 +201,13 @@ describe('', () => { StaticStore.config.auth_providers = ['anonymous']; jest.spyOn(api, 'anonymousSignin').mockImplementationOnce(async () => ({} as User)); - const { getByText, getByPlaceholderText, getByRole } = render(); + render(); - fireEvent.click(getByText('Sign In')); - fireEvent.change(getByPlaceholderText('Username'), { target: { value: 'username' } }); - fireEvent.click(getByText('Submit')); - expect(getByRole('presentation')).toHaveClass('spinner'); - expect(getByRole('presentation')).toHaveAttribute('aria-label', 'Loading...'); + fireEvent.click(screen.getByText('Sign In')); + fireEvent.change(screen.getByPlaceholderText('Username'), { target: { value: 'username' } }); + fireEvent.click(screen.getByText('Submit')); + expect(screen.getByRole('presentation')).toHaveClass('spinner'); + expect(screen.getByRole('presentation')).toHaveAttribute('aria-label', 'Loading...'); await waitFor(() => expect(api.anonymousSignin).toBeCalled()); }); @@ -219,10 +219,10 @@ describe('', () => { `('should remove spaces in the first/last position in username', async ({ value, expected }) => { StaticStore.config.auth_providers = ['email']; - const { getByText, getByPlaceholderText } = render(); - fireEvent.click(getByText('Sign In')); + render(); + fireEvent.click(screen.getByText('Sign In')); - const input = getByPlaceholderText('Username'); + const input = screen.getByPlaceholderText('Username'); fireEvent.change(input, { target: { value } }); fireEvent.blur(input); @@ -238,15 +238,82 @@ describe('', () => { `('should leave spaces in the middle of username', ({ value, expected }) => { StaticStore.config.auth_providers = ['email']; - const { getByText, getByPlaceholderText } = render(); + render(); - fireEvent.click(getByText('Sign In')); + fireEvent.click(screen.getByText('Sign In')); - const input = getByPlaceholderText('Username'); + const input = screen.getByPlaceholderText('Username'); fireEvent.change(input, { target: { value } }); fireEvent.blur(input); expect(input).toHaveValue(expected); }); + + describe('OAuth providers', () => { + it('should not set user if unauthorized', async () => { + StaticStore.config.auth_providers = ['google']; + + const setUser = jest.spyOn(userActions, 'setUser').mockImplementation(jest.fn()); + const oauthSignin = jest.spyOn(api, 'oauthSignin').mockImplementation(async () => null); + + render(); + fireEvent.click(screen.getByText('Sign In')); + await waitFor(() => fireEvent.click(screen.getByTitle('Sign In with Google'))); + await waitFor(() => + expect(oauthSignin).toBeCalledWith( + `${BASE_URL}/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark` + ) + ); + expect(setUser).toBeCalledTimes(0); + expect(screen.getByText('Sign In')).toBeInTheDocument(); + }); + + it('should set user if authorized', async () => { + StaticStore.config.auth_providers = ['google']; + + const user = { name: 'UserName1' } as User; + const setUser = jest.spyOn(userActions, 'setUser').mockImplementation(jest.fn()); + const oauthSignin = jest.spyOn(api, 'oauthSignin').mockImplementation(async () => user); + + render(); + + fireEvent.click(screen.getByText('Sign In')); + await waitFor(() => fireEvent.click(screen.getByTitle('Sign In with Google'))); + + await waitFor(() => + expect(oauthSignin).toBeCalledWith( + `${BASE_URL}/auth/google/login?from=http%3A%2F%2Flocalhost%2F%3FselfClose&site=remark` + ) + ); + expect(setUser).toBeCalledWith(user); + }); + }); + + describe('Telegram auth', () => { + it('should go through the auth flow', async () => { + StaticStore.config.auth_providers = ['telegram']; + + const user = { name: 'UserName1' } as User; + const getTelegramSigninParams = jest + .spyOn(api, 'getTelegramSigninParams') + .mockImplementationOnce(async () => ({ bot: 'botid', token: 'tokentokentoken' })); + const verifyTelegramSignin = jest.spyOn(api, 'verifyTelegramSignin').mockImplementationOnce(async () => user); + const setUser = jest.spyOn(userActions, 'setUser').mockImplementation(jest.fn()); + render(); + + fireEvent.click(screen.getByText('Sign In')); + fireEvent.click(screen.getByTitle('Sign In with Telegram')); + await waitFor(() => expect(getTelegramSigninParams).toBeCalledTimes(1)); + const telegramLink = screen.getByText('by the link').getAttribute('href'); + expect(typeof telegramLink === 'string').toBe(true); + const telegramUrl = new URL(telegramLink as string); + expect(telegramUrl.origin).toBe('https://t.me'); + expect(telegramUrl.searchParams.get('start')).toBe('tokentokentoken'); + expect(telegramUrl.pathname.startsWith(`/botid`)).toBeTruthy(); + fireEvent.click(screen.getByText('Check')); + await waitFor(() => expect(verifyTelegramSignin).toBeCalledTimes(1)); + await waitFor(() => expect(setUser).toHaveBeenCalledWith(user)); + }); + }); }); diff --git a/frontend/app/components/auth/auth.tsx b/frontend/app/components/auth/auth.tsx index f5f83e8b..8a1db599 100644 --- a/frontend/app/components/auth/auth.tsx +++ b/frontend/app/components/auth/auth.tsx @@ -1,10 +1,11 @@ import clsx from 'clsx'; -import { h, Fragment } from 'preact'; -import { useState } from 'preact/hooks'; +import { h, Fragment, JSX } from 'preact'; +import { useState, useRef } from 'preact/hooks'; import { useIntl } from 'react-intl'; -import { useSelector, useDispatch } from 'react-redux'; +import { useDispatch } from 'react-redux'; -import { setTelegramParams, setUser } from 'store/user/actions'; +import { BASE_URL, API_BASE } from 'common/constants.config'; +import { setUser } from 'store/user/actions'; import { Input } from 'components/input'; import { CrossIcon } from 'components/icons/cross'; import { TextareaAutosize } from 'components/textarea-autosize'; @@ -15,23 +16,22 @@ import { Button } from './components/button'; import { OAuth } from './components/oauth'; import { messages } from './auth.messsages'; import { useDropdown } from './auth.hooks'; -import { getProviders, getTokenInvalidReason } from './auth.utils'; +import { getProviders, getTokenInvalidReason, useErrorMessage } from './auth.utils'; import { + oauthSignin, emailSignin, verifyEmailSignin, anonymousSignin, verifyTelegramSignin, getTelegramSigninParams, } from './auth.api'; -import { StoreState } from 'store'; import styles from './auth.module.css'; -import { BASE_URL, API_BASE } from '../../common/constants.config'; export function Auth() { const intl = useIntl(); + const telegramParamsRef = useRef(null); const dispatch = useDispatch(); - const telegramParams = useSelector((s: StoreState) => s.telegramParams); const [oauthProviders, formProviders] = getProviders(); // UI State @@ -40,24 +40,57 @@ export function Auth() { const [ref, isDropdownShown, toggleDropdownState] = useDropdown(view === 'token' || view === 'telegram'); // Errors - const [invalidReason, setInvalidReason] = useState(null); + const [errorMessage, setError] = useErrorMessage(); function handleClickSingIn(evt: Event) { evt.preventDefault(); toggleDropdownState(); } + function resetView() { + setView(formProviders[0]); + setError(null); + } + function handleDropdownClose(evt: Event) { evt.preventDefault(); - setView(formProviders[0]); + resetView(); toggleDropdownState(); } + function handleClickBack(evt: JSX.TargetedMouseEvent) { + evt.preventDefault(); + resetView(); + } + + async function handleOauthClick(evt: JSX.TargetedMouseEvent) { + evt.preventDefault(); + + const { href, dataset } = evt.currentTarget; + + if (dataset.providerName?.toLowerCase() === 'telegram') { + telegramParamsRef.current = await getTelegramSigninParams(); + window.open(`https://t.me/${telegramParamsRef.current.bot}/?start=${telegramParamsRef.current.token}`); + setView('telegram'); + setError(null); + return; + } + + const user = await oauthSignin(href); + + if (user === null) { + // TODO: add error message when user is null + return; + } + + dispatch(setUser(user)); + } + function handleProviderChange(evt: Event) { const { value } = evt.currentTarget as HTMLInputElement; - setInvalidReason(null); setView(value as typeof formProviders[number]); + setError(null); } async function handleSubmit(evt: Event) { @@ -65,7 +98,7 @@ export function Auth() { evt.preventDefault(); setLoading(true); - setInvalidReason(null); + setError(null); try { switch (view) { @@ -89,7 +122,7 @@ export function Auth() { const invalidReason = getTokenInvalidReason(token); if (invalidReason) { - setInvalidReason(invalidReason); + setError(invalidReason); } else { const user = await verifyEmailSignin(token); dispatch(setUser(user)); @@ -99,50 +132,41 @@ export function Auth() { } } } catch (e) { - setInvalidReason(e.message || e.error); + setError(e); } setLoading(false); } - async function handleTelegramClick() { - if (!telegramParams) { - const params = await getTelegramSigninParams(); - if (params === null) { - return; - } - dispatch(setTelegramParams(params)); - } - setView && setView('telegram'); - } - async function handleTelegramSubmit(evt: Event) { evt.preventDefault(); setLoading(true); - setInvalidReason(null); - if (telegramParams) { - try { - const user = await verifyTelegramSignin(telegramParams.token); - dispatch(setUser(user)); - setView(formProviders[0]); - dispatch(setTelegramParams(null)); - } catch (e) { - setInvalidReason(e.message || e.error); - } - setLoading(false); + setError(null); + + if (telegramParamsRef.current === null) { + telegramParamsRef.current = await getTelegramSigninParams(); } + + try { + const user = await verifyTelegramSignin(telegramParamsRef.current.token); + + dispatch(setUser(user)); + } catch (e) { + setError(e); + } + + setLoading(false); } function handleShowEmailStep(evt: Event) { evt.preventDefault(); setView('email'); + setError(null); } const hasOAuthProviders = oauthProviders.length > 0; const hasFormProviders = formProviders.length > 0; - const errorMessage = - invalidReason !== null && messages[invalidReason] ? intl.formatMessage(messages[invalidReason]) : invalidReason; - const isTokenView = view === 'token'; + const formFooterJSX = ( <> {errorMessage &&
{errorMessage}
} @@ -151,6 +175,7 @@ export function Auth() { ); + return (
{errorMessage &&
{errorMessage}
} - ) : isTokenView ? ( + ) : view === 'token' ? ( <>
@@ -270,7 +287,7 @@ export function Auth() {
{intl.formatMessage(messages.oauthSource)}
- + )} {hasOAuthProviders && hasFormProviders && ( diff --git a/frontend/app/components/auth/auth.utils.ts b/frontend/app/components/auth/auth.utils.ts index 1e3c7dbd..6dee0307 100644 --- a/frontend/app/components/auth/auth.utils.ts +++ b/frontend/app/components/auth/auth.utils.ts @@ -1,4 +1,8 @@ +import { useMemo, useState } from 'preact/hooks'; +import { useIntl } from 'react-intl'; + import { isJwtExpired } from 'utils/jwt'; +import { errorMessages, RequestError } from 'utils/errorUtils'; import { StaticStore } from 'common/static-store'; import type { FormProvider, OAuthProvider } from 'common/types'; @@ -27,3 +31,38 @@ export function getTokenInvalidReason(token: string): null | keyof typeof messag return null; } + +export function useErrorMessage(): [string | null, (e: unknown) => void] { + const intl = useIntl(); + const [invalidReason, setInvalidReason] = useState(null); + + return useMemo(() => { + let errorMessage = invalidReason; + + if (invalidReason && messages[invalidReason]) { + errorMessage = intl.formatMessage(messages[invalidReason]); + } + + if (invalidReason && errorMessages[invalidReason]) { + errorMessage = intl.formatMessage(errorMessages[invalidReason]); + } + + function setError(err: unknown): void { + if (err === null) { + setInvalidReason(null); + return; + } + + if (typeof err === 'string') { + setInvalidReason(err); + return; + } + + const errorReason = err instanceof RequestError ? err.error : err instanceof Error ? err.message : 'error.0'; + + setInvalidReason(errorReason); + } + + return [errorMessage, setError]; + }, [intl, invalidReason]); +} diff --git a/frontend/app/components/auth/components/oauth.api.ts b/frontend/app/components/auth/components/oauth.api.ts deleted file mode 100644 index 1ff34ff9..00000000 --- a/frontend/app/components/auth/components/oauth.api.ts +++ /dev/null @@ -1,58 +0,0 @@ -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.spec.tsx b/frontend/app/components/auth/components/oauth.spec.tsx index bdea992d..392ac808 100644 --- a/frontend/app/components/auth/components/oauth.spec.tsx +++ b/frontend/app/components/auth/components/oauth.spec.tsx @@ -1,17 +1,8 @@ import { h } from 'preact'; -import { fireEvent, waitFor } from '@testing-library/preact'; import { render } from 'tests/utils'; - -import type { User } from 'common/types'; -import * as userActions from 'store/user/actions'; import { BASE_URL } from 'common/constants.config'; import { OAuth } from './oauth'; -import * as api from './oauth.api'; - -jest.mock('hooks/useTheme', () => ({ - useTheme: () => 'light', -})); describe('', () => { it('should have permanent class name', () => { @@ -30,34 +21,4 @@ describe('', () => { `${BASE_URL}/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( - `${BASE_URL}/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( - `${BASE_URL}/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 index bc3f2ec2..5526900b 100644 --- a/frontend/app/components/auth/components/oauth.tsx +++ b/frontend/app/components/auth/components/oauth.tsx @@ -1,16 +1,13 @@ import { h, JSX } from 'preact'; -import { useDispatch } from 'react-redux'; import clsx from 'clsx'; import { useIntl } from 'react-intl'; import type { OAuthProvider } from 'common/types'; import { siteId } from 'common/settings'; import { useTheme } from 'hooks/useTheme'; -import { setUser } from 'store/user/actions'; import { messages } from 'components/auth/auth.messsages'; -import { oauthSignin } from './oauth.api'; import { BASE_URL } from 'common/constants.config'; import { getButtonVariant, getProviderData } from './oauth.utils'; import styles from './oauth.module.css'; @@ -19,33 +16,13 @@ const location = encodeURIComponent(`${window.location.origin}${window.location. type Props = { providers: OAuthProvider[]; - handleTelegramClick?: () => Promise; + onOauthClick?(evt: JSX.TargetedMouseEvent): void; }; -export function OAuth({ providers, handleTelegramClick }: Props) { +export function OAuth({ providers, onOauthClick }: Props) { const intl = useIntl(); - const dispatch = useDispatch(); const theme = useTheme(); const buttonVariant = getButtonVariant(providers.length); - const handleOauthClick: JSX.GenericEventHandler = async (evt) => { - const { href } = evt.currentTarget as HTMLAnchorElement; - - evt.preventDefault(); - const user = await oauthSignin(href); - - if (user === null) { - return; - } - - dispatch(setUser(user)); - }; - - const onTelegramClick: JSX.EventHandler> = async (evt) => { - evt.preventDefault(); - if (handleTelegramClick) { - await handleTelegramClick(); - } - }; return (
    @@ -54,28 +31,17 @@ export function OAuth({ providers, handleTelegramClick }: Props) { return (
  • - {name === 'Telegram' ? ( - - ) : ( - - - - )} + + +
  • ); })} diff --git a/frontend/app/store/user/actions.ts b/frontend/app/store/user/actions.ts index dd581199..73780ad3 100644 --- a/frontend/app/store/user/actions.ts +++ b/frontend/app/store/user/actions.ts @@ -1,6 +1,6 @@ import * as api from 'common/api'; import { logout } from 'components/auth/auth.api'; -import { User, BlockedUser, BlockTTL, TelegramParams } from 'common/types'; +import { User, BlockedUser, BlockTTL } from 'common/types'; import { ttlToTime } from 'utils/ttl-to-time'; import { getHiddenUsers } from 'utils/get-hidden-users'; import { LS_EMAIL_KEY, LS_HIDDEN_USERS_KEY } from 'common/constants'; @@ -17,8 +17,6 @@ import { USER_UNHIDE, USER_SUBSCRIPTION_SET, USER_SET_ACTION, - TELEGRAM_PARAMS_SET, - TELEGRAM_PARAMS_SET_ACTION, } from './types'; import { fetchComments, unsetCommentMode } from '../comments/actions'; import { COMMENTS_PATCH } from '../comments/types'; @@ -30,13 +28,6 @@ export function setUser(user: User | null = null): USER_SET_ACTION { }; } -export function setTelegramParams(telegramParams: TelegramParams | null = null): TELEGRAM_PARAMS_SET_ACTION { - return { - type: TELEGRAM_PARAMS_SET, - telegramParams, - }; -} - export function signout(cleanSession = true): StoreAction> { return async (dispatch) => { if (cleanSession) { diff --git a/frontend/app/store/user/reducers.ts b/frontend/app/store/user/reducers.ts index 3a7ddacd..f45f48ff 100644 --- a/frontend/app/store/user/reducers.ts +++ b/frontend/app/store/user/reducers.ts @@ -1,4 +1,4 @@ -import type { User, BlockedUser, TelegramParams } from 'common/types'; +import type { User, BlockedUser } from 'common/types'; import { USER_SET, @@ -10,8 +10,6 @@ import { USER_HIDE, USER_UNHIDE, USER_SUBSCRIPTION_SET, - TELEGRAM_PARAMS_SET, - TELEGRAM_PARAMS_SET_ACTION, } from './types'; export const user = (state: User | null = null, action: USER_ACTIONS): User | null => { @@ -75,16 +73,3 @@ export const hiddenUsers = (state: { [id: string]: User } = {}, action: USER_ACT return state; } }; - -export const telegramParams = ( - state: TelegramParams | null = null, - action: TELEGRAM_PARAMS_SET_ACTION -): TelegramParams | null => { - switch (action.type) { - case TELEGRAM_PARAMS_SET: { - return action.telegramParams; - } - default: - return state; - } -}; diff --git a/frontend/app/store/user/types.ts b/frontend/app/store/user/types.ts index 562a5769..55147401 100644 --- a/frontend/app/store/user/types.ts +++ b/frontend/app/store/user/types.ts @@ -1,19 +1,12 @@ -import { User, BlockedUser, TelegramParams } from 'common/types'; +import { User, BlockedUser } from 'common/types'; export const USER_SET = 'USER/SET'; -export const TELEGRAM_PARAMS_SET = 'TELEGRAM_PARAMS/SET'; - export interface USER_SET_ACTION { type: typeof USER_SET; user: User | null; } -export interface TELEGRAM_PARAMS_SET_ACTION { - type: typeof TELEGRAM_PARAMS_SET; - telegramParams: TelegramParams | null; -} - /** * Set list of banned users */