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
This commit is contained in:
Pavel Mineev
2022-02-07 22:28:19 -06:00
parent 7395198843
commit b97b1f9462
13 changed files with 323 additions and 305 deletions
+3 -1
View File
@@ -47,7 +47,9 @@ export const removeMyComment = (id: Comment['id']): Promise<void> =>
export const getPreview = (text: string): Promise<string> => apiFetcher.post('/preview', {}, { text });
export const getUser = (): Promise<User | null> => apiFetcher.get<User | null>('/user').catch(() => null);
export function getUser(): Promise<User | null> {
return apiFetcher.get<User | null>('/user').catch(() => null);
}
export const uploadImage = (image: File): Promise<Image> => {
const data = new FormData();
-5
View File
@@ -167,8 +167,3 @@ export interface ApiError {
/** in-depth explanation */
error: string;
}
export interface TelegramParams {
bot: string;
token: string;
}
+62 -2
View File
@@ -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<User> {
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<User | null> {
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<TelegramParams> {
export function getTelegramSigninParams(): Promise<{
bot: string;
token: string;
}> {
return authFetcher.get(TELEGRAM_SIGNIN_ENDPOINT);
}
@@ -1,6 +1,6 @@
import { defineMessages } from 'react-intl';
export const messages = defineMessages({
export const messages = defineMessages<string>({
signin: {
id: 'auth.signin',
defaultMessage: 'Sign In',
+132 -65
View File
@@ -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('<Auth/>', () => {
let defaultProviders = StaticStore.config.auth_providers;
@@ -31,23 +31,23 @@ describe('<Auth/>', () => {
});
it('should close dropdown by click on button', () => {
const { container, getByText } = render(<Auth />);
const { container } = render(<Auth />);
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(<Auth />);
const { container } = render(<Auth />);
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('<Auth/>', () => {
});
it('should close dropdown by message from parent', async () => {
const { container, getByText } = render(<Auth />);
const { container } = render(<Auth />);
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('<Auth/>', () => {
] as [OAuthProvider[]][])('should renders with %j providers', async (providers) => {
StaticStore.config.auth_providers = providers;
const { container, getByText, getByTitle, queryByPlaceholderText, queryByText } = render(<Auth />);
const { container } = render(<Auth />);
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(<Auth />);
render(<Auth />);
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(<Auth />);
render(<Auth />);
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(<Auth />);
render(<Auth />);
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('<Auth/>', () => {
jest.spyOn(api, 'verifyEmailSignin').mockImplementationOnce(async () => ({} as User));
jest.spyOn(utils, 'getTokenInvalidReason').mockImplementationOnce(() => null);
const { getByText, getByPlaceholderText, getByTitle, getByRole } = render(<Auth />);
render(<Auth />);
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('<Auth/>', () => {
StaticStore.config.auth_providers = ['anonymous'];
jest.spyOn(api, 'anonymousSignin').mockImplementationOnce(async () => ({} as User));
const { getByText, getByPlaceholderText, getByRole } = render(<Auth />);
render(<Auth />);
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('<Auth/>', () => {
`('should remove spaces in the first/last position in username', async ({ value, expected }) => {
StaticStore.config.auth_providers = ['email'];
const { getByText, getByPlaceholderText } = render(<Auth />);
fireEvent.click(getByText('Sign In'));
render(<Auth />);
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('<Auth/>', () => {
`('should leave spaces in the middle of username', ({ value, expected }) => {
StaticStore.config.auth_providers = ['email'];
const { getByText, getByPlaceholderText } = render(<Auth />);
render(<Auth />);
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(<Auth />);
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(<Auth />);
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(<Auth />);
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));
});
});
});
+70 -53
View File
@@ -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 | { bot: string; token: string }>(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<keyof typeof messages | null>(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<HTMLButtonElement>) {
evt.preventDefault();
resetView();
}
async function handleOauthClick(evt: JSX.TargetedMouseEvent<HTMLAnchorElement>) {
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 && <div className={clsx('auth-error', styles.error)}>{errorMessage}</div>}
@@ -151,6 +175,7 @@ export function Auth() {
</Button>
</>
);
return (
<div className={clsx('auth', styles.root)}>
<Button className="auth-button" selected={isDropdownShown} onClick={handleClickSingIn} suffix={<ArrowIcon />}>
@@ -159,16 +184,11 @@ export function Auth() {
{isDropdownShown && (
<div className={clsx('auth-dropdown', styles.dropdown)} ref={ref}>
<form className={clsx('auth-form', styles.form)} onSubmit={handleSubmit}>
{view === 'telegram' && telegramParams ? (
{view === 'telegram' && telegramParamsRef.current !== null ? (
<>
<div className={clsx('auth-row', styles.row)}>
<div className={styles.backButton}>
<Button
className="auth-back-button"
size="xs"
kind="transparent"
onClick={() => setView(formProviders[0])}
>
<Button className="auth-back-button" size="xs" kind="transparent" onClick={handleClickBack}>
<svg
className={styles.backButtonArrow}
width="14"
@@ -198,10 +218,7 @@ export function Auth() {
</div>
<p className={clsx('telegram', styles.telegram)}>
{intl.formatMessage(messages.telegramMessage1)}{' '}
<a
href={`https://t.me/${telegramParams.bot}/?start=${telegramParams.token}`}
className="comment-form__markdown-link"
>
<a href={`https://t.me/${telegramParamsRef.current.bot}/?start=${telegramParamsRef.current.token}`}>
{intl.formatMessage(messages.telegramLink)}
</a>
{window.screen.width >= 768 && ` ${intl.formatMessage(messages.telegramOptionalQR)}`}{' '}
@@ -211,7 +228,7 @@ export function Auth() {
</p>
{window.screen.width >= 768 && (
<img
src={`${BASE_URL}${API_BASE}/qr/telegram?url=https://t.me/${telegramParams.bot}/?start=${telegramParams.token}`}
src={`${BASE_URL}${API_BASE}/qr/telegram?url=https://t.me/${telegramParamsRef.current.bot}/?start=${telegramParamsRef.current.token}`}
className={clsx('telegram-qr', styles.telegramQR)}
alt={'telegram QR-code'}
/>
@@ -221,7 +238,7 @@ export function Auth() {
</Button>
{errorMessage && <div className={clsx('auth-error', styles.error)}>{errorMessage}</div>}
</>
) : isTokenView ? (
) : view === 'token' ? (
<>
<div className={clsx('auth-row', styles.row)}>
<div className={styles.backButton}>
@@ -270,7 +287,7 @@ export function Auth() {
<h5 className={clsx('auth-form-title', styles.title)}>
{intl.formatMessage(messages.oauthSource)}
</h5>
<OAuth providers={oauthProviders} handleTelegramClick={handleTelegramClick} />
<OAuth providers={oauthProviders} onOauthClick={handleOauthClick} />
</>
)}
{hasOAuthProviders && hasFormProviders && (
@@ -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<string | null>(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]);
}
@@ -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<User | null> {
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);
});
}
@@ -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('<OAuth />', () => {
it('should have permanent class name', () => {
@@ -30,34 +21,4 @@ describe('<OAuth />', () => {
`${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(<OAuth providers={['google']} />);
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(<OAuth providers={['google']} />);
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({});
});
});
@@ -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<void>;
onOauthClick?(evt: JSX.TargetedMouseEvent<HTMLAnchorElement>): 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<HTMLAnchorElement> = 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<JSX.TargetedMouseEvent<HTMLButtonElement>> = async (evt) => {
evt.preventDefault();
if (handleTelegramClick) {
await handleTelegramClick();
}
};
return (
<ul className={clsx('oauth', styles.root)}>
@@ -54,28 +31,17 @@ export function OAuth({ providers, handleTelegramClick }: Props) {
return (
<li key={name} className={clsx('oauth-item', styles.item)}>
{name === 'Telegram' ? (
<button
onClick={onTelegramClick}
className={clsx('oauth-button telegram-auth', styles.button, styles[buttonVariant], styles[p])}
data-provider-name={name}
title={intl.formatMessage(messages.oauthTitle, { provider: name })}
>
<img className="oauth-icon telegram-auth" src={icon} width="20" height="20" alt="" aria-hidden={true} />
</button>
) : (
<a
target="_blank"
rel="noopener noreferrer"
href={`${BASE_URL}/auth/${p}/login?from=${location}&site=${siteId}`}
onClick={handleOauthClick}
className={clsx('oauth-button', styles.button, styles[buttonVariant], styles[p])}
data-provider-name={name}
title={intl.formatMessage(messages.oauthTitle, { provider: name })}
>
<img className="oauth-icon" src={icon} width="20" height="20" alt="" aria-hidden={true} />
</a>
)}
<a
target="_blank"
rel="noopener noreferrer"
href={`${BASE_URL}/auth/${p}/login?from=${location}&site=${siteId}`}
onClick={onOauthClick}
className={clsx('oauth-button', styles.button, styles[buttonVariant], styles[p])}
data-provider-name={name}
title={intl.formatMessage(messages.oauthTitle, { provider: name })}
>
<img className="oauth-icon" src={icon} width="20" height="20" alt="" aria-hidden={true} />
</a>
</li>
);
})}
+1 -10
View File
@@ -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<Promise<void>> {
return async (dispatch) => {
if (cleanSession) {
+1 -16
View File
@@ -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;
}
};
+1 -8
View File
@@ -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
*/