Merge pull request #1107 from Ksinia/master
Add telegram auth to frontend
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<User> {
|
||||
return authFetcher.get<User>('/anonymous/login', { user, aud: siteId });
|
||||
@@ -23,6 +25,79 @@ 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<{
|
||||
bot: string;
|
||||
token: string;
|
||||
}> {
|
||||
return authFetcher.get(TELEGRAM_SIGNIN_ENDPOINT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Second step of two of `telegram` authorization
|
||||
*/
|
||||
export function verifyTelegramSignin(token: string): Promise<User> {
|
||||
return authFetcher.get(TELEGRAM_SIGNIN_ENDPOINT, { token });
|
||||
}
|
||||
|
||||
export function logout(): Promise<void> {
|
||||
return authFetcher.get('/logout');
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const clickInsideRef = useRef<boolean>(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<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,6 +1,6 @@
|
||||
import { defineMessages } from 'react-intl';
|
||||
|
||||
export const messages = defineMessages({
|
||||
export const messages = defineMessages<string>({
|
||||
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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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('<Auth/>', () => {
|
||||
let defaultProviders = StaticStore.config.auth_providers;
|
||||
@@ -35,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);
|
||||
@@ -59,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}', '*');
|
||||
@@ -81,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 () => {
|
||||
@@ -146,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');
|
||||
@@ -205,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());
|
||||
});
|
||||
|
||||
@@ -223,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);
|
||||
@@ -242,14 +238,82 @@ describe('<Auth/>', () => {
|
||||
`('should leave spaces in the middle of username', ({ value, expected }) => {
|
||||
StaticStore.config.auth_providers = ['email'];
|
||||
|
||||
const { getByText, getByPlaceholderText } = render(<Auth />);
|
||||
fireEvent.click(getByText('Sign In'));
|
||||
render(<Auth />);
|
||||
|
||||
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(<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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 | { bot: string; token: string }>(null);
|
||||
const dispatch = useDispatch();
|
||||
const [oauthProviders, formProviders] = getProviders();
|
||||
|
||||
// UI State
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const [view, setView] = useState<typeof formProviders[number] | 'token'>(formProviders[0]);
|
||||
const [ref, isDropdownShown, toggleDropdownState] = useDropdown(view === 'token');
|
||||
const [view, setView] = useState<typeof formProviders[number] | 'token' | 'telegram'>(formProviders[0]);
|
||||
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) {
|
||||
@@ -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 && <div className={clsx('auth-error', styles.error)}>{errorMessage}</div>}
|
||||
@@ -114,6 +175,7 @@ export function Auth() {
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={clsx('auth', styles.root)}>
|
||||
<Button className="auth-button" selected={isDropdownShown} onClick={handleClickSingIn} suffix={<ArrowIcon />}>
|
||||
@@ -122,7 +184,61 @@ export function Auth() {
|
||||
{isDropdownShown && (
|
||||
<div className={clsx('auth-dropdown', styles.dropdown)} ref={ref}>
|
||||
<form className={clsx('auth-form', styles.form)} onSubmit={handleSubmit}>
|
||||
{isTokenView ? (
|
||||
{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={handleClickBack}>
|
||||
<svg
|
||||
className={styles.backButtonArrow}
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.75 3L5 7.25L9 11"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{intl.formatMessage(messages.back)}
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
className={clsx('auth-close-button', styles.closeButton)}
|
||||
title="Close sign-in dropdown"
|
||||
onClick={handleDropdownClose}
|
||||
>
|
||||
<CrossIcon />
|
||||
</button>
|
||||
</div>
|
||||
<p className={clsx('telegram', styles.telegram)}>
|
||||
{intl.formatMessage(messages.telegramMessage1)}{' '}
|
||||
<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)}`}{' '}
|
||||
{intl.formatMessage(messages.telegramMessage2)}
|
||||
<br />
|
||||
{intl.formatMessage(messages.telegramMessage3)}
|
||||
</p>
|
||||
{window.screen.width >= 768 && (
|
||||
<img
|
||||
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'}
|
||||
/>
|
||||
)}
|
||||
<Button key="submit" className="auth-submit" type="submit" onClick={handleTelegramSubmit}>
|
||||
{intl.formatMessage(messages.telegramCheck)}
|
||||
</Button>
|
||||
{errorMessage && <div className={clsx('auth-error', styles.error)}>{errorMessage}</div>}
|
||||
</>
|
||||
) : view === 'token' ? (
|
||||
<>
|
||||
<div className={clsx('auth-row', styles.row)}>
|
||||
<div className={styles.backButton}>
|
||||
@@ -171,7 +287,7 @@ export function Auth() {
|
||||
<h5 className={clsx('auth-form-title', styles.title)}>
|
||||
{intl.formatMessage(messages.oauthSource)}
|
||||
</h5>
|
||||
<OAuth providers={oauthProviders} />
|
||||
<OAuth providers={oauthProviders} onOauthClick={handleOauthClick} />
|
||||
</>
|
||||
)}
|
||||
{hasOAuthProviders && hasFormProviders && (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.6 18.2c.2.1.5.2.8 0a1 1 0 0 0 .6-.6l3-14.4a.6.6 0 0 0-.2-.6.6.6 0 0 0-.6 0L.4 9.1a.7.7 0 0 0-.4.6c0 .3.2.5.5.6l4.3 1.3L6.6 17a.7.7 0 0 0 .4.4.7.7 0 0 0 .7-.1l2.4-2.4 4.5 3.3Zm-8.8-7 1.4 4.5.3-2.8 8-7.3a.2.2 0 0 0 0-.3.2.2 0 0 0-.2 0l-9.5 6Z" fill="#2DA6E0"/></svg>
|
||||
|
After Width: | Height: | Size: 393 B |
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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('<OAuth />', () => {
|
||||
it('should have permanent class name', () => {
|
||||
@@ -34,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,25 +16,13 @@ const location = encodeURIComponent(`${window.location.origin}${window.location.
|
||||
|
||||
type Props = {
|
||||
providers: OAuthProvider[];
|
||||
onOauthClick?(evt: JSX.TargetedMouseEvent<HTMLAnchorElement>): 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<HTMLAnchorElement> = async (evt) => {
|
||||
const { href } = evt.currentTarget as HTMLAnchorElement;
|
||||
|
||||
evt.preventDefault();
|
||||
const user = await oauthSignin(href);
|
||||
|
||||
if (user === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setUser(user));
|
||||
};
|
||||
|
||||
return (
|
||||
<ul className={clsx('oauth', styles.root)}>
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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": "Адключыць каментары",
|
||||
|
||||
@@ -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": "Забрани коментарите",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ä",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "コメントの無効化",
|
||||
|
||||
@@ -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": "댓글 비활성화",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Отключить комментарии",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Вимкнути коментарі",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "禁用评论",
|
||||
|
||||
Reference in New Issue
Block a user