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 69097e9e..46a67d8a 100644 --- a/frontend/app/common/types.ts +++ b/frontend/app/common/types.ts @@ -95,7 +95,16 @@ export interface Tree { info: PostInfo; } -export type OAuthProvider = 'facebook' | 'twitter' | 'google' | 'yandex' | 'github' | 'microsoft' | 'patreon' | 'dev'; +export type OAuthProvider = + | 'facebook' + | 'twitter' + | 'google' + | 'yandex' + | 'github' + | 'microsoft' + | 'patreon' + | 'telegram' + | 'dev'; export type FormProvider = 'email' | 'anonymous'; export type Provider = OAuthProvider | FormProvider; diff --git a/frontend/app/components/auth/auth.api.ts b/frontend/app/components/auth/auth.api.ts index 8b0b7a25..972fbe59 100644 --- a/frontend/app/components/auth/auth.api.ts +++ b/frontend/app/components/auth/auth.api.ts @@ -2,8 +2,10 @@ 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'; export function anonymousSignin(user: string): Promise { return authFetcher.get('/anonymous/login', { user, aud: siteId }); @@ -23,6 +25,79 @@ 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<{ + bot: string; + token: string; +}> { + return authFetcher.get(TELEGRAM_SIGNIN_ENDPOINT); +} + +/** + * Second step of two of `telegram` authorization + */ +export function verifyTelegramSignin(token: string): Promise { + return authFetcher.get(TELEGRAM_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 index 5384d072..668bde96 100644 --- a/frontend/app/components/auth/auth.hooks.ts +++ b/frontend/app/components/auth/auth.hooks.ts @@ -1,5 +1,9 @@ -import { useEffect, useRef, useState } from 'preact/hooks'; +import { useEffect, useRef, useState, useMemo } from 'preact/hooks'; +import { useIntl } from 'react-intl'; + +import { errorMessages, RequestError } from 'utils/errorUtils'; import { parseMessage, postMessageToParent } from 'utils/post-message'; +import { messages } from './auth.messsages'; function handleChangeIframeSize(element: HTMLElement) { const { top } = element.getBoundingClientRect(); @@ -10,6 +14,7 @@ function handleChangeIframeSize(element: HTMLElement) { export function useDropdown(disableClosing?: boolean) { const rootRef = useRef(null); + const clickInsideRef = useRef(false); const [showDropdown, setShowDropdown] = useState(false); const toggleDropdownState = () => { setShowDropdown((s) => !s); @@ -32,18 +37,29 @@ export function useDropdown(disableClosing?: boolean) { setShowDropdown(false); } - function handleClickOutside(evt: MouseEvent) { - if (disableClosing || dropdownElement?.contains(evt.target as HTMLDivElement)) { + function handleClickOutside() { + const isClickInside = clickInsideRef.current; + + clickInsideRef.current = false; + + if (disableClosing || isClickInside) { return; } setShowDropdown(false); } + function handleClickInside() { + clickInsideRef.current = true; + } + + // check if click is inside dropdown on capture phase + dropdownElement.addEventListener('click', handleClickInside, { capture: true }); document.addEventListener('click', handleClickOutside); window.addEventListener('message', handleMessageFromParent); return () => { + dropdownElement.removeEventListener('click', handleClickInside); document.removeEventListener('click', handleClickOutside); window.removeEventListener('message', handleMessageFromParent); }; @@ -74,3 +90,38 @@ export function useDropdown(disableClosing?: boolean) { return [rootRef, showDropdown, toggleDropdownState] as const; } + +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/auth.messsages.ts b/frontend/app/components/auth/auth.messsages.ts index de97e803..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', @@ -57,6 +57,30 @@ export const messages = defineMessages({ id: 'auth.submit', defaultMessage: 'Submit', }, + telegramLink: { + id: 'auth.telegram-link', + defaultMessage: 'by the link', + }, + telegramCheck: { + id: 'auth.telegram-check', + defaultMessage: 'Check', + }, + telegramMessage1: { + id: 'auth.telegram-message-1', + defaultMessage: 'Open the Telegram', + }, + telegramOptionalQR: { + id: 'auth.telegram-optional-qr', + defaultMessage: 'or by scanning the QR code', + }, + telegramMessage2: { + id: 'auth.telegram-message-2', + defaultMessage: 'and click “Start” there.', + }, + telegramMessage3: { + id: 'auth.telegram-message-3', + defaultMessage: 'Afterwards, click “Check” below.', + }, openProfile: { id: 'auth.open-profile', defaultMessage: 'Open My Profile', diff --git a/frontend/app/components/auth/auth.module.css b/frontend/app/components/auth/auth.module.css index 594b4042..9120cfcd 100644 --- a/frontend/app/components/auth/auth.module.css +++ b/frontend/app/components/auth/auth.module.css @@ -195,3 +195,13 @@ color: var(--error-color); line-height: 1.2; } + +.telegramQR { + display: block; + margin: 0 auto 0 auto; + width: 75%; +} + +.telegram { + margin-bottom: 0; +} diff --git a/frontend/app/components/auth/auth.spec.tsx b/frontend/app/components/auth/auth.spec.tsx index aa06a512..eed4e987 100644 --- a/frontend/app/components/auth/auth.spec.tsx +++ b/frontend/app/components/auth/auth.spec.tsx @@ -1,23 +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('react-redux', () => ({ - useDispatch: () => jest.fn(), -})); - -jest.mock('hooks/useTheme', () => ({ - useTheme: () => 'light', -})); +window.open = jest.fn(); describe('', () => { let defaultProviders = StaticStore.config.auth_providers; @@ -35,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); @@ -59,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}', '*'); @@ -81,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 () => { @@ -146,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'); @@ -205,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()); }); @@ -223,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); @@ -242,14 +238,82 @@ describe('', () => { `('should leave spaces in the middle of username', ({ value, expected }) => { StaticStore.config.auth_providers = ['email']; - const { getByText, getByPlaceholderText } = render(); - fireEvent.click(getByText('Sign In')); + render(); - const input = getByPlaceholderText('Username'); + fireEvent.click(screen.getByText('Sign In')); + + 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 4eefbb69..75bd66b5 100644 --- a/frontend/app/components/auth/auth.tsx +++ b/frontend/app/components/auth/auth.tsx @@ -1,9 +1,10 @@ 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 { useDispatch } from 'react-redux'; +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'; @@ -14,41 +15,82 @@ import { ArrowIcon } from 'components/icons/arrow'; import { Button } from './components/button'; import { OAuth } from './components/oauth'; import { messages } from './auth.messsages'; -import { useDropdown } from './auth.hooks'; +import { useDropdown, useErrorMessage } from './auth.hooks'; import { getProviders, getTokenInvalidReason } from './auth.utils'; -import { emailSignin, verifyEmailSignin, anonymousSignin } from './auth.api'; +import { + oauthSignin, + emailSignin, + verifyEmailSignin, + anonymousSignin, + verifyTelegramSignin, + getTelegramSigninParams, +} from './auth.api'; import styles from './auth.module.css'; export function Auth() { const intl = useIntl(); + const telegramParamsRef = useRef(null); const dispatch = useDispatch(); const [oauthProviders, formProviders] = getProviders(); // UI State const [isLoading, setLoading] = useState(false); - const [view, setView] = useState(formProviders[0]); - const [ref, isDropdownShown, toggleDropdownState] = useDropdown(view === 'token'); + const [view, setView] = useState(formProviders[0]); + 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) { @@ -56,7 +98,7 @@ export function Auth() { evt.preventDefault(); setLoading(true); - setInvalidReason(null); + setError(null); try { switch (view) { @@ -80,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)); @@ -90,7 +132,27 @@ export function Auth() { } } } catch (e) { - setInvalidReason(e.message || e.error); + setError(e); + } + + setLoading(false); + } + + async function handleTelegramSubmit(evt: Event) { + evt.preventDefault(); + setLoading(true); + 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); @@ -99,13 +161,12 @@ export function Auth() { 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}
} @@ -114,6 +175,7 @@ export function Auth() { ); + return (
+
+ + +

+ {intl.formatMessage(messages.telegramMessage1)}{' '} + + {intl.formatMessage(messages.telegramLink)} + + {window.screen.width >= 768 && ` ${intl.formatMessage(messages.telegramOptionalQR)}`}{' '} + {intl.formatMessage(messages.telegramMessage2)} +
+ {intl.formatMessage(messages.telegramMessage3)} +

+ {window.screen.width >= 768 && ( + {'telegram + )} + + {errorMessage &&
{errorMessage}
} + + ) : view === 'token' ? ( <>
@@ -171,7 +287,7 @@ export function Auth() {
{intl.formatMessage(messages.oauthSource)}
- + )} {hasOAuthProviders && hasFormProviders && ( diff --git a/frontend/app/components/auth/components/assets/telegram.svg b/frontend/app/components/auth/components/assets/telegram.svg new file mode 100644 index 00000000..79cae933 --- /dev/null +++ b/frontend/app/components/auth/components/assets/telegram.svg @@ -0,0 +1 @@ + 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.consts.ts b/frontend/app/components/auth/components/oauth.consts.ts index ac39dff9..e10e80b8 100644 --- a/frontend/app/components/auth/components/oauth.consts.ts +++ b/frontend/app/components/auth/components/oauth.consts.ts @@ -13,6 +13,7 @@ export const OAUTH_DATA = { dark: require('./assets/github-dark.svg').default as string, }, }, + telegram: require('./assets/telegram.svg').default as string, } as const; export const OAUTH_PROVIDERS = Object.keys(OAUTH_DATA); diff --git a/frontend/app/components/auth/components/oauth.spec.tsx b/frontend/app/components/auth/components/oauth.spec.tsx index 1622c3a1..392ac808 100644 --- a/frontend/app/components/auth/components/oauth.spec.tsx +++ b/frontend/app/components/auth/components/oauth.spec.tsx @@ -1,21 +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('react-redux', () => ({ - useDispatch: () => jest.fn(), -})); - -jest.mock('hooks/useTheme', () => ({ - useTheme: () => 'light', -})); describe('', () => { it('should have permanent class name', () => { @@ -34,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 d48466a7..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,25 +16,13 @@ const location = encodeURIComponent(`${window.location.origin}${window.location. type Props = { providers: OAuthProvider[]; + onOauthClick?(evt: JSX.TargetedMouseEvent): void; }; -export function OAuth({ providers }: 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)); - }; return (
    @@ -50,7 +35,7 @@ export function OAuth({ providers }: Props) { target="_blank" rel="noopener noreferrer" href={`${BASE_URL}/auth/${p}/login?from=${location}&site=${siteId}`} - onClick={handleOauthClick} + onClick={onOauthClick} className={clsx('oauth-button', styles.button, styles[buttonVariant], styles[p])} data-provider-name={name} title={intl.formatMessage(messages.oauthTitle, { provider: name })} diff --git a/frontend/app/locales/be.json b/frontend/app/locales/be.json index 9c999869..d0485a69 100644 --- a/frontend/app/locales/be.json +++ b/frontend/app/locales/be.json @@ -10,6 +10,12 @@ "auth.signout": "Выйсці?", "auth.submit": "Адправіць", "auth.symbols-restriction": "імя карыстальніка мусіць пачынацца з літары і ўтрымоўваць толькі лацінскія літары, лічбы, знакі падкрэслівання і прабелы(?)", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Карыстальнік не знойдзены", "auth.username": "Імя карыстальніка", "authPanel.disable-comments": "Адключыць каментары", diff --git a/frontend/app/locales/bg.json b/frontend/app/locales/bg.json index 7685c329..86b3668c 100644 --- a/frontend/app/locales/bg.json +++ b/frontend/app/locales/bg.json @@ -10,6 +10,12 @@ "auth.signout": "Изход", "auth.submit": "Изпрати", "auth.symbols-restriction": "Потребителското име трябва да започва с буква и да бъде само от латински букви, цифри, подчертавки или разтояния", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Не бе намерен потребител", "auth.username": "Потребителско име", "authPanel.disable-comments": "Забрани коментарите", diff --git a/frontend/app/locales/bp.json b/frontend/app/locales/bp.json index 9e41d8e6..14128b4a 100644 --- a/frontend/app/locales/bp.json +++ b/frontend/app/locales/bp.json @@ -10,6 +10,12 @@ "auth.signout": "Fazer logout", "auth.submit": "Enviar", "auth.symbols-restriction": "O nome de usuário deve conter apenas letras, números, sublinhados ou espaços", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Nenhum usuário encontrado", "auth.username": "Nome do usuário", "authPanel.disable-comments": "Desabilitar comentários", diff --git a/frontend/app/locales/de.json b/frontend/app/locales/de.json index d641f528..8b931514 100644 --- a/frontend/app/locales/de.json +++ b/frontend/app/locales/de.json @@ -10,6 +10,12 @@ "auth.signout": "Abmelden", "auth.submit": "Absenden", "auth.symbols-restriction": "Der Benutzername darf nur Buchstaben, Zahlen, Unterstriche oder Leerzeichen enthalten", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Benutzer konnte nicht gefunden werden", "auth.username": "Benutzername", "authPanel.disable-comments": "Kommentarfunktion deaktivieren", diff --git a/frontend/app/locales/en.json b/frontend/app/locales/en.json index 9d7a76aa..3a61b421 100644 --- a/frontend/app/locales/en.json +++ b/frontend/app/locales/en.json @@ -10,6 +10,12 @@ "auth.signout": "Sign Out", "auth.submit": "Submit", "auth.symbols-restriction": "Username must contain only letters, numbers, underscores or spaces", + "auth.telegram-check": "Check", + "auth.telegram-link": "by the link", + "auth.telegram-message-1": "Open the Telegram", + "auth.telegram-message-2": "and click “Start” there.", + "auth.telegram-message-3": "Afterwards, click “Check” below.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "No user was found", "auth.username": "Username", "authPanel.disable-comments": "Disable comments", diff --git a/frontend/app/locales/es.json b/frontend/app/locales/es.json index 7d72e06e..68110025 100644 --- a/frontend/app/locales/es.json +++ b/frontend/app/locales/es.json @@ -10,6 +10,12 @@ "auth.signout": "¿Salir?", "auth.submit": "Enviar", "auth.symbols-restriction": "El nombre de usuario debe comenzar con una letra y contener solamente letras latinas, números, guión bajo o espacio", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "No se encontró el usuario", "auth.username": "Nombre de usuario", "authPanel.disable-comments": "Deshabilitar comentarios", diff --git a/frontend/app/locales/fi.json b/frontend/app/locales/fi.json index 88944c13..84c99fad 100644 --- a/frontend/app/locales/fi.json +++ b/frontend/app/locales/fi.json @@ -10,6 +10,12 @@ "auth.signout": "Kirjaudu ulos", "auth.submit": "Lähetä", "auth.symbols-restriction": "Käyttäjätunnuksen tulee alkaa kirjaimella ja sisältää vain latinalaisia kirjaimia, numeroita, alaviivoja ja välilyöntejä", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Käyttäjää ei löytynyt", "auth.username": "Käyttäjätunnus", "authPanel.disable-comments": "Poista kommentit käytöstä", diff --git a/frontend/app/locales/fr.json b/frontend/app/locales/fr.json index c0080a1b..02ca17eb 100644 --- a/frontend/app/locales/fr.json +++ b/frontend/app/locales/fr.json @@ -10,6 +10,12 @@ "auth.signout": "Se déconnecter", "auth.submit": "Valider", "auth.symbols-restriction": "Le nom d'utilisateur ne doit contenir que des lettres, nombres, tirets bas et espaces.", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Aucun utilisateur n'a été trouvé", "auth.username": "Nom d'utilisateur", "authPanel.disable-comments": "Désactiver les commentaires", diff --git a/frontend/app/locales/it.json b/frontend/app/locales/it.json index 350f908f..8bfa077b 100644 --- a/frontend/app/locales/it.json +++ b/frontend/app/locales/it.json @@ -10,6 +10,12 @@ "auth.signout": "Esci", "auth.submit": "Invia", "auth.symbols-restriction": "L'username deve contenere solo lettere, numberi, trattini bassi o spazi", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Nessun utente trovato", "auth.username": "Username", "authPanel.disable-comments": "Disabilita commenti", @@ -157,8 +163,8 @@ "toolbar.ordered-list": "Aggiungi lista numerata", "toolbar.quote": "Inserisci citazione", "toolbar.unordered-list": "Aggiungi lista a pallini", - "user.my-comments": "My comments", "user.comments": "Comments", + "user.my-comments": "My comments", "vote.anonymous": "Gli utenti anonimi non possono votare", "vote.deleted": "Non puoi votare per un commento eliminato", "vote.guest": "Accedi per votare", diff --git a/frontend/app/locales/ja.json b/frontend/app/locales/ja.json index 1c58f916..56c90a59 100644 --- a/frontend/app/locales/ja.json +++ b/frontend/app/locales/ja.json @@ -10,6 +10,12 @@ "auth.signout": "ログアウトしますか", "auth.submit": "送信", "auth.symbols-restriction": "ユーザー名には英数字、アンダースコア、またはスペースのみを指定してください", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "ユーザーが見つかりませんでした", "auth.username": "ユーザー名", "authPanel.disable-comments": "コメントの無効化", diff --git a/frontend/app/locales/ko.json b/frontend/app/locales/ko.json index 82714a0a..b3edd76e 100644 --- a/frontend/app/locales/ko.json +++ b/frontend/app/locales/ko.json @@ -10,6 +10,12 @@ "auth.signout": "로그아웃하시겠어요", "auth.submit": "확인", "auth.symbols-restriction": "사용자 이름에는 문자, 숫자, 밑줄 또는 공백만 포함되어야 합니다", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "사용자를 찾을 수 없습니다", "auth.username": "사용자 이름", "authPanel.disable-comments": "댓글 비활성화", diff --git a/frontend/app/locales/pl.json b/frontend/app/locales/pl.json index ad459bb3..a359621f 100644 --- a/frontend/app/locales/pl.json +++ b/frontend/app/locales/pl.json @@ -10,6 +10,12 @@ "auth.signout": "Wyloguj", "auth.submit": "Potwierdź", "auth.symbols-restriction": "Nazwa użytkownika powinna składać się z liter, numerów, podkreślinków lub spacji", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Żaden użytkownik nie został znaleziony", "auth.username": "Nazwa użytkownika", "authPanel.disable-comments": "Wyłącz komentarze", diff --git a/frontend/app/locales/ru.json b/frontend/app/locales/ru.json index 0109750e..89bf46c8 100644 --- a/frontend/app/locales/ru.json +++ b/frontend/app/locales/ru.json @@ -10,6 +10,12 @@ "auth.signout": "Выйти", "auth.submit": "Отправить", "auth.symbols-restriction": "Имя пользователя должно начинаться с буквы и содержать только латинские буквы, цифры, знаки подчеркивания и пробелы", + "auth.telegram-check": "Проверить", + "auth.telegram-link": "по ссылке", + "auth.telegram-message-1": "Откройте Телеграм", + "auth.telegram-message-2": "и нажмите там кнопку “Start”/“Начать”.", + "auth.telegram-message-3": "Затем нажмите кнопку “Проверить”.", + "auth.telegram-optional-qr": "или по QR-коду", "auth.user-not-found": "Пользователь не найден", "auth.username": "Имя пользователя", "authPanel.disable-comments": "Отключить комментарии", diff --git a/frontend/app/locales/tr.json b/frontend/app/locales/tr.json index a52c0cb4..e7b9d0b1 100644 --- a/frontend/app/locales/tr.json +++ b/frontend/app/locales/tr.json @@ -10,6 +10,12 @@ "auth.signout": "Çıkış yap", "auth.submit": "Gönder", "auth.symbols-restriction": "Kullanıcı adı harf ile başlayıp; yalnızca harf, rakam, alt çizgi veya boşluk içerebilir", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Kullanıcı bulunamadı", "auth.username": "Kullanıcı adı", "authPanel.disable-comments": "Yorumları devre dışı bırak", diff --git a/frontend/app/locales/ua.json b/frontend/app/locales/ua.json index 29417178..de10ebbf 100644 --- a/frontend/app/locales/ua.json +++ b/frontend/app/locales/ua.json @@ -10,6 +10,12 @@ "auth.signout": "Вийти", "auth.submit": "Відправити", "auth.symbols-restriction": "Ім’я користувача повинно починатися з літери та містити тільки символи латинського алфавіту, цифри, знаки підкреслення або пробіли", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Користувач не знайдений", "auth.username": "Ім’я користувача", "authPanel.disable-comments": "Вимкнути коментарі", diff --git a/frontend/app/locales/vi.json b/frontend/app/locales/vi.json index e5dc6782..a3613932 100644 --- a/frontend/app/locales/vi.json +++ b/frontend/app/locales/vi.json @@ -10,6 +10,12 @@ "auth.signout": "Thoát", "auth.submit": "Gửi đi", "auth.symbols-restriction": "Tên người dùng chỉ được chứa các chữ cái, số, dấu gạch dưới hoặc dấu cách", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "Không tìm thấy người dùng nào", "auth.username": "Tên người dùng", "authPanel.disable-comments": "Tắt bình luận", diff --git a/frontend/app/locales/zh.json b/frontend/app/locales/zh.json index 8b3e7891..341847ef 100644 --- a/frontend/app/locales/zh.json +++ b/frontend/app/locales/zh.json @@ -10,6 +10,12 @@ "auth.signout": "登出", "auth.submit": "提交", "auth.symbols-restriction": "用户名只能包含字母、数字、下划线或空格", + "auth.telegram-check": "Check", + "auth.telegram-link": "Telegram bot", + "auth.telegram-message-1": "You need to authorize your account in the", + "auth.telegram-message-2": "by clicking “Start” there.", + "auth.telegram-message-3": "Click “Check” below after authorization in the Telegram bot.", + "auth.telegram-optional-qr": "or by scanning the QR code", "auth.user-not-found": "未找到用户", "auth.username": "用户名", "authPanel.disable-comments": "禁用评论",