Fix/410 fix languages (#483)

* feat: handle languages and save it in the backend. Issue #418. Issue #410

* test: fix test cases

* test: add test cases in the backend
This commit is contained in:
2025-05-29 17:42:32 -03:00
committed by GitHub
parent fe2803393b
commit 49e0f915e1
36 changed files with 375 additions and 103 deletions
@@ -55,5 +55,6 @@ describe('Portuguese Utils unit tests', () => {
expect(translateServerResponse(keys[17], 'pt_br')).toBe('Identificação incorreta ou faltando');
expect(translateServerResponse(keys[18], 'pt_br')).toBe('Informação errada ou incompleta!');
expect(translateServerResponse(keys[19], 'pt_br')).toBe('E-mail ou senha inválidos!');
expect(translateServerResponse(keys[20], 'pt_br')).toBe('Nada para atualizar!');
});
});
@@ -76,5 +76,6 @@ describe('Russian Utils unit tests', () => {
expect(translateServerResponse(keys[17], 'ru')).toBe('Неправильная или отсутствующая идентификация');
expect(translateServerResponse(keys[18], 'ru')).toBe('Неверная или отсутствующая информация!');
expect(translateServerResponse(keys[19], 'ru')).toBe('Неправильный пользователь или пароль');
expect(translateServerResponse(keys[20], 'ru')).toBe('Нечего обновлять!');
});
});
@@ -54,5 +54,7 @@ describe('Spanish Utils unit tests', () => {
expect(translateServerResponse(keys[16], 'es')).toBe('Error desconocido');
expect(translateServerResponse(keys[17], 'es')).toBe('Identificación incorrecta o faltante');
expect(translateServerResponse(keys[18], 'es')).toBe('¡Información incorrecta o incompleta!');
expect(translateServerResponse(keys[19], 'es')).toBe('¡Usuario o contraseña incorrectos!');
expect(translateServerResponse(keys[20], 'es')).toBe('¡Nada que actualizar!');
});
});
+12 -10
View File
@@ -36,7 +36,8 @@ const authContextMock = {
email: 'test@example.com',
admin: false,
createdAt: new Date(),
gravatarImageUrl: 'http://image.com'
gravatarImageUrl: 'http://image.com',
lang: 'en'
},
checkCurrentAuthUser: vi.fn(),
signIn: vi.fn(),
@@ -61,7 +62,7 @@ describe('Account Component', () => {
it('should render the Account component', () => {
const { getByText } = renderAccount();
expect(getByText('Update only what you need. Blank fields will not be updated')).toBeDefined();
expect(getByText('account_data_update_header')).toBeDefined();
});
it('should change language when a language button is clicked', () => {
@@ -96,12 +97,12 @@ describe('Account Component', () => {
const { getByLabelText, getByText, getByTestId } = renderAccount();
fireEvent.change(getByLabelText(/First name/i), { target: { value: 'Jane' } });
fireEvent.change(getByLabelText(/Email/i), { target: { value: 'jane.doe@example.com' } });
fireEvent.change(getByLabelText('account_form_first_name_label'), { target: { value: 'Jane' } });
fireEvent.change(getByLabelText('login_email_label'), { target: { value: 'jane.doe@example.com' } });
fireEvent.change(getByTestId('account-password-one'), { target: { value: 'password123' } });
fireEvent.change(getByLabelText(/Repeat password/i), { target: { value: 'password123' } });
fireEvent.change(getByLabelText(/register_password_repeat_label/i), { target: { value: 'password123' } });
fireEvent.click(getByText(/Save/i));
fireEvent.click(getByText('account_form_save'));
await waitFor(() => {
expect(mockPatchJSON).toHaveBeenCalledWith(expect.any(String), {
@@ -109,6 +110,7 @@ describe('Account Component', () => {
email: 'jane.doe@example.com',
password: 'password123',
passwordAgain: 'password123',
lang: ''
});
});
@@ -118,10 +120,10 @@ describe('Account Component', () => {
it('should render text based on new contentHeader component', () => {
const { getByText } = renderAccount();
expect(getByText('My')).toBeDefined();
expect(getByText('Account')).toBeDefined();
expect(getByText('account_header_my')).toBeDefined();
expect(getByText('account_header_account')).toBeDefined();
expect(getByText('account_my_account_hello')).toBeDefined();
expect(getByText('Update and Manage, Your')).toBeDefined();
expect(getByText('Data')).toBeDefined();
expect(getByText('account_header_update_manage')).toBeDefined();
expect(getByText('account_header_data')).toBeDefined();
});
});
+4 -2
View File
@@ -39,6 +39,7 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
const [passwordAgain, setPasswordAgain] = useState<string>('');
const [secondsLeft, setSecondsLeft] = useState<number>(0);
const [isResendEnabled, setIsResendEnabled] = useState(true);
const [langToSave, setLangToSave] = useState<string>('');
/**
* Navigates to the specified page.
@@ -87,7 +88,7 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
goTo('/home');
}
else if (prefix === 'register') {
await register(email, password, passwordAgain);
await register({ email, password, passwordAgain, lang: langToSave });
// Do not clear the email, because user might request to resend
setPassword('');
setPasswordAgain('');
@@ -139,7 +140,8 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
};
useEffect(() => {
handleDefaultLang();
const lang = handleDefaultLang();
setLangToSave(lang);
}, [formInvalid]);
useEffect(() => {
+20 -2
View File
@@ -114,6 +114,11 @@ const enTranslations = {
note_table_btn_edit: 'Edit',
note_table_btn_delete: 'Delete',
about_page_title_one: 'About the',
about_page_title_two: 'TaskNote App',
about_page_title_three: 'Tasks and notes made',
about_page_title_four: 'Easy',
about_page_subtitle: 'Find more information about us and the app',
about_app_title: 'About TaskNote',
about_app_description: `TaskNote is your go-to application for managing
tasks and notes in one convenient place. Whether you're keeping track of
@@ -146,13 +151,26 @@ const enTranslations = {
passionate about building applications that make life easier and more
organized. You can reach out to me at `,
about_dev_description_two: ' for any questions or feedback.',
about_buy_coffee_one: 'You can also ',
about_buy_coffee_link: 'Buy me a coffee',
account_my_account_title: 'My Account',
account_header_my: 'My',
account_header_account: 'Account',
account_header_update_manage: 'Update and Manage, Your',
account_header_data: 'Data',
account_data_update_header: 'Update only what you need. Blank fields will not be updated',
account_form_first_name_label: 'Fist name',
account_form_first_name_placeholder: 'Your name',
account_form_save: 'Save',
account_form_gravatar_one: 'If you want to display your picture, we support Gravatar. Please head to ',
account_form_gravatar_two: ' to register or update your profile picture. Once updated, please wait a few seconds to see it here.',
account_my_account_hello: 'Hello! This is where you can manage your preferences',
account_my_account_logged: 'You\'re logged in as: ',
account_app_lang_title: 'App Language',
account_app_lang_title: 'Change the app language',
account_app_lang_description: 'You can choose one the languages below',
account_app_lang_available: 'Available languages',
account_privacy_little: 'Your Privacy Matters',
account_privacy_subtitle: 'You decide when to delete your data',
account_privacy_text: `We're committed to protecting your privacy and
giving you full control over your data. You can request complete account
deletion at any time. Once processed, all your personal information will be
+4 -1
View File
@@ -84,6 +84,7 @@ export const serverResponsesTranslations: Record<string, string> = {
UNKNOWN_pt_br: 'Erro desconhecido',
WRONG_IDENTIFICATION_pt_br: 'Identificação incorreta ou faltando',
WRONG_OR_MISSING_INFO_pt_br: 'Informação errada ou incompleta!',
NOTHING_TO_UPDATE_pt_br: 'Nada para atualizar!',
BAD_PASSWORD_3_es: 'Contraseña inválida: La contraseña debe tener al menos 8 caracteres, 1 mayúscula y 1 carácter especial',
BAD_PASSWORD_2_es: 'Contraseña inválida: La contraseña debe tener al menos 1 mayúscula y 1 carácter especial',
@@ -105,6 +106,7 @@ export const serverResponsesTranslations: Record<string, string> = {
UNKNOWN_es: 'Error desconocido',
WRONG_IDENTIFICATION_es: 'Identificación incorrecta o faltante',
WRONG_OR_MISSING_INFO_es: '¡Información incorrecta o incompleta!',
NOTHING_TO_UPDATE_es: '¡Nada que actualizar!',
BAD_PASSWORD_3_ru: 'Неправильный пароль: Пароль должен содержать не менее 8 символов, 1 заглавную букву, 1 специальный символ.',
BAD_PASSWORD_2_ru: 'Неправильный пароль: Пароль должен содержать как минимум 1 заглавную букву и 1 специальный символ.',
@@ -125,5 +127,6 @@ export const serverResponsesTranslations: Record<string, string> = {
RECOVER_PASSWORD_ru: 'Если введенный вами адрес электронной почты связан с учетной записью, вы вскоре получите ссылку для сброса пароля.',
UNKNOWN_ru: 'Неизвестная ошибка',
WRONG_IDENTIFICATION_ru: 'Неправильная или отсутствующая идентификация',
WRONG_OR_MISSING_INFO_ru: 'Неверная или отсутствующая информация!'
WRONG_OR_MISSING_INFO_ru: 'Неверная или отсутствующая информация!',
NOTHING_TO_UPDATE_ru: 'Нечего обновлять!'
};
+20 -2
View File
@@ -114,6 +114,11 @@ const ptBrTranslations = {
note_table_btn_edit: 'Alterar',
note_table_btn_delete: 'Excluir',
about_page_title_one: 'Sobre o',
about_page_title_two: 'App TaskNote',
about_page_title_three: 'Tarefas e notas de forma',
about_page_title_four: 'Fácil',
about_page_subtitle: 'Saiba mais sobre nós e sobre o app',
about_app_title: 'Sobre o TaskNote',
about_app_description: `TaskNote é sua aplicação ideal para gerenciamento
de tarefas e notas em apenas um lugar. Esteja você buscando registrar sua
@@ -147,13 +152,26 @@ const ptBrTranslations = {
Sou apaixonado por criar aplicações que fazem a vida mais fácil e organizada.
Você pode me contactar em `,
about_dev_description_two: ' para qualquer dúvida ou comentário.',
about_buy_coffee_one: 'Você também pode ',
about_buy_coffee_link: 'Me pagar um café',
account_my_account_title: 'Minha conta',
account_header_my: 'Minha',
account_header_account: 'Conta',
account_header_update_manage: 'Atualize e Gerencie, Seus',
account_header_data: 'Dados',
account_data_update_header: 'Atualize apenas o que você precisa. Campos em branco não serão atualizados',
account_form_first_name_label: 'Primeiro nome',
account_form_first_name_placeholder: 'Seu nome',
account_form_save: 'Salvar',
account_form_gravatar_one: 'Se você quer adicionar sua foto, nós suportamos Gravatar. Por favor acesse ',
account_form_gravatar_two: ' para se registrar ou atualizar seu perfil. Uma vez atualizado, por favor aguarde alguns segundos para vê-la aqui.',
account_my_account_hello: 'Olá! Aqui é onde você pode gerenciar suas preferências.',
account_my_account_logged: 'Você está logado como: ',
account_app_lang_title: 'Idioma do App',
account_app_lang_title: 'Mude o idioma do app',
account_app_lang_description: 'Você pode escolher um destes idiomas:',
account_app_lang_available: 'Idiomas disponívels',
account_privacy_little: 'Sua Privacidade Importa',
account_privacy_subtitle: 'Você decide quanto excluir seus dados',
account_privacy_text: `Estamos comprometidos em proteger sua privacidade e te dar
controle total sobre seus dados. Você pode solicitar a remoção completa da sua conta
a qualquer hora. Uma vez processado, todos os seus dados pessoais serão excluídos
+20 -2
View File
@@ -114,6 +114,11 @@ const ruTranslations = {
note_table_btn_edit: 'Редактировать',
note_table_btn_delete: 'Удалить',
about_page_title_one: 'около',
about_page_title_two: 'TaskNote App',
about_page_title_three: 'Задачи и заметки стали',
about_page_title_four: 'проще',
about_page_subtitle: ' Узнайте больше о нас и приложении',
about_app_title: 'О TaskNote',
about_app_description: `TaskNote — это ваше приложение для управления
задачами и заметками в одном удобном месте. Независимо от того, отслеживаете ли вы
@@ -146,13 +151,26 @@ const ruTranslations = {
которые делают жизнь проще и более
организованной. Вы можете связаться со мной по адресу `,
about_dev_description_two: ' для любых вопросов или отзывов.',
about_buy_coffee_one: 'Ты также можешь ',
about_buy_coffee_link: 'купить мне кофе.',
account_my_account_title: 'Мой аккаунт',
account_header_my: 'Мой',
account_header_account: 'аккаунт',
account_header_update_manage: 'Обновление и управление',
account_header_data: 'данные',
account_data_update_header: 'Обновляйте только то, что вам нужно. Пустые поля не будут обновлены',
account_form_first_name_label: 'имя',
account_form_first_name_placeholder: 'Ваше имя',
account_form_save: 'Сохранить',
account_form_gravatar_one: 'Если вы хотите показать свою фотографию, мы поддерживаем Gravatar. Пожалуйста, перейдите на ',
account_form_gravatar_two: ' для регистрации или обновления вашего фото профиля. После обновления, пожалуйста, подождите несколько секунд, чтобы увидеть его здесь.',
account_my_account_hello: 'Привет! Здесь вы можете управлять своими предпочтениями.',
account_my_account_logged: 'Вы вошли как:',
account_app_lang_title: 'Язык приложения',
account_app_lang_title: 'Изменить язык приложения',
account_app_lang_description: 'Вы можете выбрать один из этих языков:',
account_app_lang_available: 'Доступные языки',
account_privacy_little: 'Ваша конфиденциальность имеет значение',
account_privacy_subtitle: 'Вы сами решаете, когда удалять свои данные',
account_privacy_text: `Мы стремимся защищать вашу конфиденциальность и
предоставлять вам полный контроль над вашими данными. Вы можете запросить полное удаление
аккаунта в любое время. После обработки вся ваша личная информация будет
+2 -1
View File
@@ -18,5 +18,6 @@ export const serverResponses: Record<string, string> = {
'Unknown error': 'UNKNOWN',
'Wrong or missing identification': 'WRONG_IDENTIFICATION',
'Wrong or missing information!': 'WRONG_OR_MISSING_INFO',
'Invalid credentials': 'INVALID_CREDENTIALS'
'Invalid credentials': 'INVALID_CREDENTIALS',
'Nothing to update!': 'NOTHING_TO_UPDATE'
};
+20 -2
View File
@@ -114,6 +114,11 @@ const esTranslations = {
note_table_btn_edit: 'Editar',
note_table_btn_delete: 'Eliminar',
about_page_title_one: 'Acerca de',
about_page_title_two: 'TaskNote App',
about_page_title_three: 'Tareas y notas',
about_page_title_four: 'Simplificadas',
about_page_subtitle: 'Encuentre más información sobre nosotros y la aplicación.',
about_app_title: 'Acerca de TaskNote',
about_app_description: `TaskNote es tu aplicación ideal para gestionar
tareas y notas en un solo lugar. Ya sea que estés organizando tareas diarias
@@ -146,13 +151,26 @@ const esTranslations = {
Me apasiona crear aplicaciones que faciliten la vida y ayuden a organizarse.
Puedes contactarme en `,
about_dev_description_two: ' para cualquier duda o comentario.',
about_buy_coffee_one: 'También puedes ',
about_buy_coffee_link: 'Comprarme un café',
account_my_account_title: 'Mi Cuenta',
account_header_my: 'Mi',
account_header_account: 'Cuenta',
account_header_update_manage: 'Actualize y Administre, Sus',
account_header_data: 'Datos',
account_data_update_header: 'Actualice solo lo necesario. Los campos en blanco no se actualizarán',
account_form_first_name_label: 'Nombre de pila',
account_form_first_name_placeholder: 'Su nombre',
account_form_save: 'Guardar',
account_form_gravatar_one: 'Si quieres mostrar tu foto, admitimos Gravatar. Visita ',
account_form_gravatar_two: ' Para registrarte o actualizar tu foto de perfil. Una vez actualizada, espera unos segundos para verla aquí.',
account_my_account_hello: '¡Hola! Aquí puedes gestionar tus preferencias.',
account_my_account_logged: 'Has iniciado sesión como: ',
account_app_lang_title: 'Idioma del App',
account_app_lang_title: 'Cambia el idioma del App',
account_app_lang_description: 'Puedes elegir uno de estos idiomas:',
account_app_lang_available: 'Idiomas disponíbles',
account_privacy_little: 'Su Privacidad es Importante',
account_privacy_subtitle: 'Tú decides cuándo eliminar tus datos',
account_privacy_text: `Nos comprometemos a proteger su privacidad y a brindarle
control total sobre sus datos. Puedes solicitar la eliminación completa de su
cuenta en cualquier momento. Una vez procesada, toda su información personal
+2 -1
View File
@@ -1,5 +1,6 @@
import { createContext } from 'react';
import { UserResponse } from '../types/UserResponse';
import { UserRegistration } from '../types/UserRegistration';
export interface AuthContextData {
signed: boolean;
@@ -7,7 +8,7 @@ export interface AuthContextData {
checkCurrentAuthUser: (pathname: string) => Promise<void>;
signIn: (email: string, password: string) => Promise<string>;
signOut: () => void;
register: (email: string, password: string, passwordAgain: string) => Promise<string>;
register: (payload: UserRegistration) => Promise<string>;
isAdmin: boolean;
updateUser: (userUpdated: UserResponse) => void;
}
+4 -3
View File
@@ -5,6 +5,7 @@ import { SigninResponse } from '../types/SigninResponse';
import api from '../api-service/api';
import ApiConfig from '../api-service/apiConfig';
import { UserResponse } from '../types/UserResponse';
import { UserRegistration } from '../types/UserRegistration';
interface Props {
children: React.ReactNode;
@@ -72,9 +73,8 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
}
};
const register = async (email: string, password: string, passwordAgain: string): Promise<string> => {
const register = async (payload: UserRegistration): Promise<string> => {
try {
const payload = { email, password, passwordAgain };
await api.putJSON(ApiConfig.registerUrl, payload);
return Promise.resolve('OK');
}
@@ -96,7 +96,8 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
email: registerResponse.email,
admin: registerResponse.admin,
createdAt: new Date(registerResponse.createdAt),
gravatarImageUrl: registerResponse.gravatarImageUrl
gravatarImageUrl: registerResponse.gravatarImageUrl,
lang: registerResponse.lang
};
setSigned(true);
+3 -2
View File
@@ -11,11 +11,12 @@ function handleLanguage(lang: string) {
setDefaultLang(lang);
}
const handleDefaultLang = () => {
const lang = getDefaultLang();
const handleDefaultLang = (langFromServer?: string): string => {
const lang = langFromServer ?? getDefaultLang();
if (lang !== 'en') {
handleLanguage(lang);
}
return lang;
};
export { handleDefaultLang };
+1
View File
@@ -6,4 +6,5 @@ export type SigninResponse = {
createdAt: Date;
token: string;
gravatarImageUrl: string;
lang: string;
};
+1
View File
@@ -3,4 +3,5 @@ export type UserPatchRequest = {
email: string | null;
password: string | null;
passwordAgain: string | null;
lang: string | null;
};
+6
View File
@@ -0,0 +1,6 @@
export type UserRegistration = {
email: string;
password: string;
passwordAgain: string;
lang: string;
};
+1
View File
@@ -5,4 +5,5 @@ export type UserResponse = {
admin: boolean;
createdAt: Date;
gravatarImageUrl: string;
lang: string;
};
+9 -10
View File
@@ -17,11 +17,11 @@ function About(): React.ReactNode {
return (
<Container fluid>
<ContentHeader
h1TextRegular="About the"
h1TextBold="TaskNote App"
subtitle="Find more information about us and the app"
h2BlackText="Tasks and notes made"
h2GreenText="Easy"
h1TextRegular={t('about_page_title_one')}
h1TextBold={t('about_page_title_two')}
subtitle={t('about_page_subtitle')}
h2BlackText={t('about_page_title_three')}
h2GreenText={t('about_page_title_four')}
/>
<Row className="justify-content-center mb-4">
@@ -67,20 +67,19 @@ function About(): React.ReactNode {
<h2 className="mb-4 poppins-bold about-title">{t('about_dev_title')}</h2>
<p className="poppins-light">
{t('about_dev_description')}
<a href="mailto:ricardompcampos@gmail.com" className="text-decoration-none">
ricardompcampos@gmail.com
<a href="https://gravatar.com/ricardormcampos" className="text-decoration-none">
gravatar.com/ricardormcampos
</a>
{t('about_dev_description_two')}
</p>
<p className="poppins-light">
You can also
{' '}
{t('about_buy_coffee_one')}
<a
href="https://buy-me-a-coffee-two-nu.vercel.app/"
target="_blank"
rel="noreferrer"
>
Buy me a coffee
{t('about_buy_coffee_link')}
</a>
</p>
</Card.Body>
+51 -29
View File
@@ -40,6 +40,7 @@ function Account(): React.ReactNode {
const handleLanguage = (lang: string): void => {
i18n.changeLanguage(lang);
setDefaultLang(lang);
patchLanguage(lang);
};
/**
@@ -92,31 +93,48 @@ function Account(): React.ReactNode {
setValidated(false);
};
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
const patchLanguage = async (lang: string): Promise<void> => {
const patchPayload: UserPatchRequest = {
name: null,
email: '',
password: '',
passwordAgain: '',
lang: lang
};
await patchUserInfo(patchPayload);
};
const handleSubmit = async (event?: React.FormEvent<HTMLFormElement>): Promise<void> => {
event?.preventDefault();
event?.stopPropagation();
setValidated(true);
setErrorMessage('');
const form = event.currentTarget;
const form = event?.currentTarget;
const patchPayload: UserPatchRequest = {
name: userName ? DOMPurify.sanitize(userName) : null,
email: userEmail,
password: userPassword,
passwordAgain: userPasswordAgain
passwordAgain: userPasswordAgain,
lang: ''
};
const sizeOfPayload = JSON.stringify(patchPayload).length;
if (sizeOfPayload === 57) {
setErrorMessage('Nothing to update!');
if (sizeOfPayload === 67) {
setErrorMessage(translateServerResponse('Nothing to update!', i18n.language));
return;
}
const updated: UserResponse | undefined = await patchUserInfo(patchPayload);
if (updated) {
form.reset();
form?.reset();
resetInputs(updated);
if (userEmail !== '') {
signOut();
clearStorage();
}
}
};
@@ -125,11 +143,11 @@ function Account(): React.ReactNode {
return (
<Container fluid>
<ContentHeader
h1TextRegular="My"
h1TextBold="Account"
h1TextRegular={t('account_header_my')}
h1TextBold={t('account_header_account')}
subtitle={t('account_my_account_hello')}
h2BlackText="Update and Manage, Your"
h2GreenText="Data"
h2BlackText={t('account_header_update_manage')}
h2GreenText={t('account_header_data')}
/>
<Row>
@@ -137,7 +155,7 @@ function Account(): React.ReactNode {
<Card className="p-4">
<Card.Body>
<Card.Title>
Update only what you need. Blank fields will not be updated
{t('account_data_update_header')}
</Card.Title>
<AlertError
@@ -148,11 +166,11 @@ function Account(): React.ReactNode {
<Form noValidate validated={validated} onSubmit={handleSubmit} className="mt-4">
{/* User name */}
<FormInput
labelText="First name"
labelText={t('account_form_first_name_label')}
iconName="Person"
required={false}
name="name"
placeholder={user?.name ?? 'Your name'}
placeholder={user?.name ?? t('account_form_first_name_placeholder')}
value={userName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setUserName(e.target.value);
@@ -161,7 +179,7 @@ function Account(): React.ReactNode {
{/* User email */}
<FormInput
labelText="Email"
labelText={t('login_email_label')}
iconName="At"
required={false}
name="email"
@@ -174,12 +192,16 @@ function Account(): React.ReactNode {
{/* User password */}
<FormInput
labelText="Password"
labelText={t('login_password_label')}
iconName="Lock"
required={false}
type="password"
name="password"
value={userPassword}
placeholder={t('login_password_placeholder')}
pwdShowText={t('password_show_txt')}
pwdHideText={t('password_hide_txt')}
pwdHelperTxt={t('password_helper')}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setUserPassword(e.target.value);
}}
@@ -188,12 +210,16 @@ function Account(): React.ReactNode {
{/* User password again */}
<FormInput
labelText="Repeat password"
labelText={t('register_password_repeat_label')}
iconName="Lock"
required={false}
type="password"
name="passwordAgain"
value={userPasswordAgain}
placeholder={t('login_password_placeholder')}
pwdShowText={t('password_show_txt')}
pwdHideText={t('password_hide_txt')}
pwdHelperTxt={t('password_helper')}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setUserPasswordAgain(e.target.value);
}}
@@ -204,19 +230,15 @@ function Account(): React.ReactNode {
type="submit"
className="home-new-item task-note-btn"
>
Save
{t('account_form_save')}
</button>
</div>
</Form>
<hr />
<p>
If you want to display your picture, we support Gravatar.
Please head to
{' '}
{t('account_form_gravatar_one')}
<a href="https://gravatar.com" target="_blank" rel="noreferrer">Gravatar</a>
{' '}
to register or update your profile picture. Once updated, please wait a few
minutes to see it here.
{t('account_form_gravatar_two')}
</p>
</Card.Body>
</Card>
@@ -224,9 +246,9 @@ function Account(): React.ReactNode {
<Col xs={12} lg={6} className="mt-4 mt-lg-0">
<Card className="p-4">
<Card.Body>
<Card.Title>Change the app language</Card.Title>
<Card.Title>{t('account_app_lang_title')}</Card.Title>
<span className="description">{t('account_app_lang_description')}</span>
<div className="mt-4 mb-2">Available languages</div>
<div className="mt-4 mb-2">{t('account_app_lang_available')}</div>
<div>
{languages.map((lang: LangAvailable) => (
<Button
@@ -258,9 +280,9 @@ function Account(): React.ReactNode {
<Card className="mt-4 p-4">
<Card.Body>
<Card.Title>Your Privacy matters</Card.Title>
<Card.Title>{t('account_privacy_little')}</Card.Title>
<span className="description">
You decide when to delete your data
{t('account_privacy_subtitle')}
</span>
<p className="mt-4 mb-2">{t('account_privacy_text')}</p>
+1 -1
View File
@@ -263,7 +263,7 @@ function Home(): React.ReactNode {
const handleCloseModal = () => setShowMarkdownView(false);
useEffect(() => {
handleDefaultLang();
handleDefaultLang(user?.lang);
setName(user?.name ?? 'User');
loadTags();
loadAllTasks();
@@ -55,9 +55,12 @@ public class UserEntity implements UserDetails {
@Column(name = "reset_password_expiration", nullable = true)
private LocalDateTime resetPasswordExpiration;
@Column(name = "reset_token", nullable = true)
@Column(name = "reset_token", nullable = true, length = 35)
private String resetToken;
@Column(name = "lang", nullable = true, length = 6)
private String lang;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of();
@@ -0,0 +1,14 @@
package br.com.tasknoteapp.server.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.server.ResponseStatusException;
/** This class represents a Bad Language exception. */
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadLanguageException extends ResponseStatusException {
public BadLanguageException() {
super(HttpStatus.BAD_REQUEST, "Invalid language");
}
}
@@ -28,6 +28,9 @@ public class LoginRequest {
@Schema(description = "User password again.")
String passwordAgain;
@Schema(description = "User language. (Optional, default English)")
String lang;
public String email() {
return email.trim().toLowerCase();
}
@@ -39,4 +42,8 @@ public class LoginRequest {
public String passwordAgain() {
return passwordAgain;
}
public String lang() {
return lang;
}
}
@@ -8,4 +8,5 @@ public record UserPatchRequest(
@Schema(description = "User first name. Optional.") String name,
@Schema(description = "User email. Optional.") String email,
@Schema(description = "User password. Optional.") String password,
@Schema(description = "User password again. Optional.") String passwordAgain) {}
@Schema(description = "User password again. Optional.") String passwordAgain,
@Schema(description = "User lang. Optional.") String lang) {}
@@ -19,7 +19,8 @@ public record UserResponseWithToken(
example = "2023-01-01T00:00:00")
LocalDateTime inactivatedAt,
@Schema(description = "The gravatar image URL, if any") String gravatarImageUrl,
@Schema(description = "The token created upon login") String token) {
@Schema(description = "The token created upon login") String token,
@Schema(description = "The language selected upon login") String lang) {
/**
* Create a {@link UserResponseWithToken} instance from a {@link UserEntity}.
@@ -38,6 +39,7 @@ public record UserResponseWithToken(
user.getCreatedAt(),
user.getInactivatedAt(),
gravatarUrl.orElse(null),
token);
token,
user.getLang());
}
}
@@ -2,6 +2,7 @@ package br.com.tasknoteapp.server.service;
import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.entity.UserPwdLimitEntity;
import br.com.tasknoteapp.server.exception.BadLanguageException;
import br.com.tasknoteapp.server.exception.BadPasswordException;
import br.com.tasknoteapp.server.exception.BadUuidException;
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
@@ -27,6 +28,7 @@ import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@@ -65,40 +67,47 @@ public class AuthService {
/**
* Create a new user in the app.
*
* @param login User details with email and password.
* @param newUser User details with email and password.
* @return Token
*/
@Transactional
public UserResponseWithToken signUpNewUser(LoginRequest login) {
log.info("Signing up new user! {}", login.email());
public UserResponseWithToken signUpNewUser(LoginRequest newUser) {
log.info("Signing up new user! {}", newUser.email());
if (findByEmail(login.email()).isPresent()) {
if (findByEmail(newUser.email()).isPresent()) {
throw new EmailAlreadyExistsException();
}
Optional<String> passwordValidation = authUtil.validatePassword(login.password());
Optional<String> passwordValidation = authUtil.validatePassword(newUser.password());
if (passwordValidation.isPresent()) {
throw new BadPasswordException(passwordValidation.get());
}
if (Objects.isNull(login.passwordAgain()) || !login.password().equals(login.passwordAgain())) {
if (Objects.isNull(newUser.passwordAgain())
|| !newUser.password().equals(newUser.passwordAgain())) {
throw new BadPasswordException("The passwords should match");
}
UUID emailUuid = new UuidUtil().generateEmailUuid(login.email());
String[] validLangs = new String[] {"en", "es", "pt_br", "ru"};
if (!Arrays.asList(validLangs).contains(newUser.lang())) {
throw new BadLanguageException();
}
UUID emailUuid = new UuidUtil().generateEmailUuid(newUser.email());
UserEntity user = new UserEntity();
user.setEmail(login.email());
user.setPassword(passwordEncoder.encode(login.password()));
user.setAdmin(login.email().equals("ricardompcampos@gmail.com"));
user.setEmail(newUser.email());
user.setPassword(passwordEncoder.encode(newUser.password()));
user.setAdmin(false);
user.setCreatedAt(LocalDateTime.now());
user.setEmailUuid(emailUuid);
user.setLang(newUser.lang());
userRepository.save(user);
mailgunEmailService.sendNewUser(user);
log.info("User created! ID {}", user.getId());
return UserResponseWithToken.fromEntity(user, null, getGravatarImageUrl(login.email()));
return UserResponseWithToken.fromEntity(user, null, getGravatarImageUrl(newUser.email()));
}
/**
@@ -256,6 +265,7 @@ public class AuthService {
String email = currentUserEmail.orElseThrow();
UserEntity currentUser = findByEmail(email).orElseThrow();
boolean shouldUpdate = false;
boolean emailChanged = false;
if (!Objects.isNull(patchRequest.name()) && !patchRequest.name().isBlank()) {
currentUser.setName(patchRequest.name().trim());
@@ -264,6 +274,11 @@ public class AuthService {
if (!Objects.isNull(patchRequest.email()) && !patchRequest.email().isBlank()) {
currentUser.setEmail(patchRequest.email().trim());
shouldUpdate = true;
emailChanged = true;
}
if (!Objects.isNull(patchRequest.lang()) && !patchRequest.lang().isBlank()) {
currentUser.setLang(patchRequest.lang());
shouldUpdate = true;
}
boolean updatePassword =
@@ -290,6 +305,12 @@ public class AuthService {
userRepository.save(currentUser);
}
if (emailChanged) {
// send email to older and new account
log.info("Email changed from {} to {}", email, patchRequest.email());
mailgunEmailService.sendEmailChangedNotification(currentUser, email);
}
return UserResponse.fromEntity(currentUser, getGravatarImageUrl(email));
}
@@ -3,6 +3,7 @@ package br.com.tasknoteapp.server.service;
import br.com.tasknoteapp.server.entity.UserEntity;
import br.com.tasknoteapp.server.exception.MailServiceException;
import br.com.tasknoteapp.server.templates.MailgunTemplate;
import br.com.tasknoteapp.server.templates.MailgunTemplateEmailChanged;
import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwd;
import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwdConfirm;
import br.com.tasknoteapp.server.templates.MailgunTemplateSignUp;
@@ -106,6 +107,25 @@ public class MailgunEmailService {
sendEmail(to, subject, resetTemplate);
}
/**
* Send a notification about the changed email for both previous and new email.
*
* @param user The user that should be addressed the message.
* @param oldEmail The user previous email
*/
public void sendEmailChangedNotification(UserEntity user, String oldEmail) {
log.info("Sending message with changed email notification");
MailgunTemplateEmailChanged emailChanged = new MailgunTemplateEmailChanged();
emailChanged.setEmailFrom(oldEmail);
emailChanged.setEmailTo(user.getEmail());
emailChanged.setCarbonCopy(oldEmail);
String subject = "TaskNote App email changed notification";
sendEmail(user.getEmail(), subject, emailChanged);
}
/**
* Send an email message.
*
@@ -124,6 +144,9 @@ public class MailgunEmailService {
MultiValueMap<String, String> mailData = new LinkedMultiValueMap<>();
mailData.add("from", from);
mailData.add("to", to);
if (template.getCarbonCopy().isPresent()) {
mailData.add("cc", template.getCarbonCopy().get());
}
mailData.add("subject", subject);
mailData.add("template", template.getName());
if (!template.getVariables().isEmpty()) {
@@ -1,6 +1,7 @@
package br.com.tasknoteapp.server.templates;
import java.util.Map;
import java.util.Optional;
/** This interface represents a mailgun template structure. */
public interface MailgunTemplate {
@@ -13,6 +14,10 @@ public interface MailgunTemplate {
Map<String, Object> getVariables();
default Optional<String> getCarbonCopy() {
return Optional.empty();
}
/**
* Default method to get variables in JSON format.
*
@@ -0,0 +1,44 @@
package br.com.tasknoteapp.server.templates;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/** This class represents a template for the email change workflow. */
public class MailgunTemplateEmailChanged implements MailgunTemplate {
private String templateName = "email changed";
private String carbonCopy;
private final Map<String, Object> props;
public MailgunTemplateEmailChanged() {
this.props = new HashMap<>();
}
public void setEmailFrom(String emailFrom) {
props.put("EMAIL_FROM", emailFrom);
}
public void setEmailTo(String emailTo) {
props.put("EMAIL_TO", emailTo);
}
@Override
public String getName() {
return templateName;
}
@Override
public Map<String, Object> getVariables() {
return props;
}
public void setCarbonCopy(String carbonCopy) {
this.carbonCopy = carbonCopy;
}
@Override
public Optional<String> getCarbonCopy() {
return Optional.ofNullable(carbonCopy);
}
}
@@ -0,0 +1,2 @@
ALTER TABLE tasknote.users
ADD lang VARCHAR(35) NULL;
@@ -32,12 +32,12 @@ class AuthenticationControllerTest {
@Test
@DisplayName("Sign up happy path should succeed")
void signup_happyPath_shouldSucceed() throws Exception {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
final String token = "xaxbxcxdx1x2x3A@";
UserResponseWithToken response =
new UserResponseWithToken(
123L, null, request.email(), false, LocalDateTime.now(), null, null, token);
123L, null, request.email(), false, LocalDateTime.now(), null, null, token, "en");
when(authService.signUpNewUser(request)).thenReturn(response);
String jsonString =
@@ -63,12 +63,12 @@ class AuthenticationControllerTest {
@Test
@DisplayName("Sign up bad email request should fail")
void signup_badEmailRequest_shouldFail() throws Exception {
LoginRequest request = new LoginRequest("user@domain..com", "abcde123456", "abcde123456");
LoginRequest request = new LoginRequest("user@domain..com", "abcde123456", "abcde123456", "en");
final String token = "xaxbxcxdx1x2x3@A";
UserResponseWithToken response =
new UserResponseWithToken(
123L, null, request.email(), false, LocalDateTime.now(), null, null, token);
123L, null, request.email(), false, LocalDateTime.now(), null, null, token, "en");
when(authService.signUpNewUser(request)).thenReturn(response);
String jsonString =
@@ -94,7 +94,7 @@ class AuthenticationControllerTest {
@Test
@DisplayName("Sign up email already exists should fail")
void signup_userAlreadyExists_shouldFail() throws Exception {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
when(authService.signUpNewUser(request)).thenThrow(new EmailAlreadyExistsException());
@@ -103,7 +103,8 @@ class AuthenticationControllerTest {
{
"email": "user@domain.com",
"password": "abcde123456",
"passwordAgain": "abcde123456"
"passwordAgain": "abcde123456",
"lang": "en"
}
""";
@@ -121,12 +122,12 @@ class AuthenticationControllerTest {
@Test
@DisplayName("Sign in happy path should succeed")
void signin_happyPath_shouldSucceed() throws Exception {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
final String token = "xaxbxcxdx1x2x3A@";
UserResponseWithToken response =
new UserResponseWithToken(
123L, null, request.email(), false, LocalDateTime.now(), null, null, token);
123L, null, request.email(), false, LocalDateTime.now(), null, null, token, "en");
when(authService.signInUser(request)).thenReturn(response);
String jsonString =
@@ -134,7 +135,8 @@ class AuthenticationControllerTest {
{
"email": "user@domain.com",
"password": "abcde123456",
"passwordAgain": "abcde123456"
"passwordAgain": "abcde123456",
"lang": "en"
}
""";
@@ -156,7 +158,7 @@ class AuthenticationControllerTest {
@Test
@DisplayName("Sign in invalid credentials should fail")
void signIn_invalidCredentials_shouldFail() throws Exception {
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456", "en");
when(authService.signInUser(request)).thenReturn(null);
@@ -69,7 +69,7 @@ class UserControllerTest {
void patchUserInfo_happyPath_shouldSucceed() throws Exception {
UserResponse response =
new UserResponse(1L, "John", "email@example.com", false, null, null, null);
UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null);
UserPatchRequest request = new UserPatchRequest("John Doe", response.email(), null, null, null);
when(authService.patchUserInfo(request)).thenReturn(response);
String jsonString =
@@ -77,7 +77,8 @@ class AuthServiceTest {
@Test
@DisplayName("SignUp new user happy path should succeed")
void signUpNewUser_happyPath_shouldSucceed() {
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!", "123456@abcde!");
LoginRequest request =
new LoginRequest("email@domain.com", "123456@abcde!", "123456@abcde!", "en");
when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty());
when(authUtil.validatePassword(request.password())).thenReturn(Optional.empty());
@@ -103,7 +104,8 @@ class AuthServiceTest {
@Test
@DisplayName("SignUp new user with existing email should fail")
void signUpNewUser_emailExists_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!", "123456@abcde!");
LoginRequest request =
new LoginRequest("email@domain.com", "123456@abcde!", "123456@abcde!", "en");
UserEntity existing = new UserEntity();
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
@@ -118,7 +120,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignUp new user with bad password should fail")
void signUpNewUser_badPassword_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456", "en");
when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty());
when(authUtil.validatePassword(request.password())).thenReturn(Optional.of("Bad password"));
@@ -190,7 +192,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignIn user happy path should succeed")
void signInUser_happyPath_shouldSucceed() {
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456", "en");
UserEntity existing = new UserEntity();
existing.setId(919L);
@@ -213,7 +215,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignIn wrong user or password should fail")
void signInUser_wrongUserOrPassword_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456", "en");
when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty());
@@ -227,7 +229,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignIn max login attempt should fail")
void signInUser_maxLoginAttempt_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456", "en");
UserEntity existing = new UserEntity();
existing.setId(919L);
@@ -251,7 +253,7 @@ class AuthServiceTest {
@Test
@DisplayName("SignIn bad credentials should fail")
void signInUser_badCredentials_shouldFail() {
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456", "en");
UserEntity existing = new UserEntity();
existing.setId(919L);
@@ -391,7 +393,8 @@ class AuthServiceTest {
when(userRepository.save(any())).thenReturn(existing);
UserPatchRequest patchRequest = new UserPatchRequest("Kong", "newemail@domain.com", null, null);
UserPatchRequest patchRequest =
new UserPatchRequest("Kong", "newemail@domain.com", null, null, null);
UserResponse response = authService.patchUserInfo(patchRequest);
Assertions.assertNotNull(response);
@@ -416,7 +419,7 @@ class AuthServiceTest {
String newPassword = "TestHackedPw@difficult!#:)";
UserPatchRequest patchRequest =
new UserPatchRequest("Kong", "newemail@domain.com", newPassword, newPassword);
new UserPatchRequest("Kong", "newemail@domain.com", newPassword, newPassword, "en");
when(authUtil.validatePassword(patchRequest.password())).thenReturn(Optional.empty());
@@ -96,4 +96,19 @@ class MailgunEmailServiceTest {
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
}
@Test
void testSendEmailChanged() {
UserEntity user = new UserEntity();
user.setEmail("test@example.com");
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
.thenReturn(ResponseEntity.ok("Success"));
String oldEmail = "old@example.com";
mailgunEmailService.sendEmailChangedNotification(user, oldEmail);
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
}
}
@@ -37,4 +37,17 @@ class MailgunTemplateTest {
Assertions.assertNotNull(signUp.getVariableValuesJson());
Assertions.assertFalse(signUp.getVariableValuesJson().isBlank());
}
@Test
void mailgunTemplateEmailChangedTest() {
// MailgunTemplateEmailChanged
MailgunTemplateEmailChanged emailChanged = new MailgunTemplateEmailChanged();
Assertions.assertNotNull(emailChanged.getName());
Assertions.assertEquals("email changed", emailChanged.getName());
Assertions.assertNotNull(emailChanged.getVariables());
Assertions.assertNotNull(emailChanged.getVariableValuesJson());
Assertions.assertFalse(emailChanged.getVariableValuesJson().isBlank());
}
}