feat: add email validation and password reset (#430)
* feat: add user confirmation email - wip issue #414 * feat: improve register flow and add confirmation email done. Issue 414 * feat: add resend email feature. Issue #414 * feat: add password reset feature. Issue #415 * test: fix and add test cases * fix: email_uuid column actually can be null * test: add mailgun service class unit tests * test: add tests for the authservice and mailgun service classes * chore: fix sonar cloud issues * test: add client test * test: add more test cases * test: fix test acse * test: add more test cases * chore: fix sonar code smells * chore: remove role element from dic
This commit is contained in:
@@ -13,6 +13,9 @@ import Landing from './views/Landing';
|
||||
import Login from './views/Login';
|
||||
import NotFound from './views/NotFound';
|
||||
import Register from './views/Register';
|
||||
import EmailConfirmation from './views/EmailConfirmation';
|
||||
import ResetPassword from './views/ResetPassword';
|
||||
import CompleteResetPassword from './views/CompleteResetPassword';
|
||||
import './styles/custom.scss';
|
||||
|
||||
/**
|
||||
@@ -49,6 +52,19 @@ function App(): React.ReactNode {
|
||||
path: '/home',
|
||||
element: <Navigate to="/login" replace />
|
||||
},
|
||||
{
|
||||
path: '/email-confirmation',
|
||||
element: <EmailConfirmation />
|
||||
},
|
||||
{
|
||||
// The reset-password is where the password reset workflow starts
|
||||
path: '/reset-password',
|
||||
element: <ResetPassword />
|
||||
},
|
||||
{
|
||||
path: '/finish-reset-password',
|
||||
element: <CompleteResetPassword />
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/" replace />
|
||||
|
||||
@@ -1,17 +1,46 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { BrowserRouter } from 'react-router';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import LoginForm from '../../components/LoginForm';
|
||||
import '../../i18n';
|
||||
import AuthContext from '../../context/AuthContext';
|
||||
|
||||
const authContextMock = {
|
||||
signed: true,
|
||||
user: {
|
||||
userId: 1,
|
||||
name: 'Ricardo',
|
||||
email: 'test@example.com',
|
||||
admin: false,
|
||||
createdAt: new Date(),
|
||||
gravatarImageUrl: 'http://image.com'
|
||||
},
|
||||
checkCurrentAuthUser: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
register: vi.fn(),
|
||||
isAdmin: false,
|
||||
updateUser: vi.fn()
|
||||
};
|
||||
|
||||
describe('LoginForm Component test', () => {
|
||||
it('should render the Login Form component correctly', () => {
|
||||
render(
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const renderFn = (prefix: string) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<LoginForm prefix="login" />
|
||||
<AuthContext.Provider value={authContextMock}>
|
||||
<LoginForm prefix={prefix} />
|
||||
</AuthContext.Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
it('should render the Login Form component correctly', () => {
|
||||
renderFn("login");
|
||||
|
||||
const loginEmailInput: HTMLInputElement = screen.getByTestId('login_email_input');
|
||||
expect(loginEmailInput).toBeDefined();
|
||||
@@ -19,14 +48,90 @@ describe('LoginForm Component test', () => {
|
||||
});
|
||||
|
||||
it('should render the Register Form component correctly', () => {
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<LoginForm prefix="register" />
|
||||
</BrowserRouter>
|
||||
);
|
||||
renderFn("register");
|
||||
|
||||
const loginEmailInput: HTMLInputElement = screen.getByTestId('register_email_input');
|
||||
expect(loginEmailInput).toBeDefined();
|
||||
expect(loginEmailInput.required).toBe(true);
|
||||
});
|
||||
|
||||
it('should render the Login Form correctly for "login" prefix', () => {
|
||||
renderFn("login");
|
||||
|
||||
const emailInput = screen.getByTestId('login_email_input');
|
||||
const passwordInput = screen.getByTestId('account-password-login');
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
|
||||
expect(emailInput).toBeDefined();
|
||||
expect(passwordInput).toBeDefined();
|
||||
expect(submitButton).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render the Register Form correctly for "register" prefix', () => {
|
||||
renderFn("register");
|
||||
|
||||
const emailInput = screen.getByTestId('register_email_input');
|
||||
const passwordInput = screen.getByTestId('account-password-login');
|
||||
const repeatPasswordInput = screen.getByTestId('account-repeat-password-register');
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i });
|
||||
|
||||
expect(emailInput).toBeDefined();
|
||||
expect(passwordInput).toBeDefined();
|
||||
expect(repeatPasswordInput).toBeDefined();
|
||||
expect(submitButton).toBeDefined();
|
||||
});
|
||||
|
||||
it('should render the Reset Password Form correctly for "reset" prefix', () => {
|
||||
renderFn("reset");
|
||||
|
||||
const emailInput = screen.getByTestId('reset_email_input');
|
||||
const submitButton = screen.getByRole('button', { name: /Send confirmation email/i });
|
||||
|
||||
expect(emailInput).toBeDefined();
|
||||
expect(submitButton).toBeDefined();
|
||||
});
|
||||
|
||||
it('should display error message when form is invalid', () => {
|
||||
renderFn("login");
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: /login/i });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
const errorMessage = screen.getByText(/please fill in your username and password/i);
|
||||
expect(errorMessage).toBeDefined();
|
||||
});
|
||||
|
||||
it('should display success message for "register" prefix after successful submission', async () => {
|
||||
renderFn("register");
|
||||
|
||||
const emailInput = screen.getByTestId('register_email_input');
|
||||
const passwordInput = screen.getByTestId('account-password-login');
|
||||
const repeatPasswordInput = screen.getByTestId('account-repeat-password-register');
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(repeatPasswordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
const successMessage = await screen.findByText(/please confirm your email before proceeding/i);
|
||||
expect(successMessage).toBeDefined();
|
||||
});
|
||||
|
||||
it('should disable resend button and show countdown for "register" prefix', async () => {
|
||||
renderFn("register");
|
||||
|
||||
const emailInput = screen.getByTestId('register_email_input');
|
||||
const passwordInput = screen.getByTestId('account-password-login');
|
||||
const repeatPasswordInput = screen.getByTestId('account-repeat-password-register');
|
||||
const submitButton = screen.getByRole('button', { name: /create account/i });
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
fireEvent.change(repeatPasswordInput, { target: { value: 'password123' } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
const resendButton = screen.queryByRole('button', { name: /resend confirmation email/i }) as HTMLButtonElement;
|
||||
expect(resendButton).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import NoteTitle from '../../components/NoteTitle';
|
||||
|
||||
describe('NoteTitle test cases', () => {
|
||||
it('should render the note title', () => {
|
||||
const { getByText } = render(<NoteTitle title="Test Title" />)
|
||||
const { getByText } = render(<NoteTitle title="Test Title" noteUrl={null} />)
|
||||
|
||||
expect(getByText('Test Title')).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ const ConsumerComponent: React.FC = () => {
|
||||
<button
|
||||
data-testid="register"
|
||||
onClick={() => {
|
||||
register('new@example.com', 'password123');
|
||||
register('new@example.com', 'password123', 'password123');
|
||||
}}
|
||||
>
|
||||
Register
|
||||
@@ -133,42 +133,7 @@ describe('AuthProvider', () => {
|
||||
expect(localStorage.getItem(USER_DATA)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should register a new user successfully', async () => {
|
||||
const fakeResponse = {
|
||||
token: 'register-token',
|
||||
userId: '456',
|
||||
name: 'New User',
|
||||
email: 'new@example.com',
|
||||
admin: false,
|
||||
createdAt: new Date(),
|
||||
gravatarImageUrl: 'http://dummyimage.com'
|
||||
};
|
||||
|
||||
vi.spyOn(api, 'putJSON').mockResolvedValue(fakeResponse);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
let getByTestIdFunction;
|
||||
await act(async () => {
|
||||
const { getByTestId } = render(
|
||||
<AuthProvider>
|
||||
<ConsumerComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
getByTestIdFunction = getByTestId;
|
||||
});
|
||||
|
||||
await waitFor(() => expect(getByTestIdFunction('register')).toBeDefined());
|
||||
|
||||
await user.click(getByTestIdFunction('register'));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestIdFunction('signed').textContent).toBe('true')
|
||||
);
|
||||
expect(getByTestIdFunction('user').textContent).toBe('New User');
|
||||
expect(localStorage.getItem(API_TOKEN)).toBe('register-token');
|
||||
expect(localStorage.getItem(USER_DATA)).not.toBeNull();
|
||||
});
|
||||
|
||||
|
||||
it('should sign out a user', async () => {
|
||||
// Pre-populate localStorage to simulate a signed-in state.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, test, vi, Mock } from 'vitest';
|
||||
import { useNavigate } from 'react-router';
|
||||
import AuthContext from '../../context/AuthContext';
|
||||
import CompleteResetPassword from '../../views/CompleteResetPassword';
|
||||
|
||||
// Mock useNavigate
|
||||
vi.mock('react-router', () => ({
|
||||
useNavigate: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock LoginForm
|
||||
vi.mock('../../components/LoginForm', () => ({
|
||||
__esModule: true,
|
||||
default: ({ prefix }: { prefix: string }) => (
|
||||
<div data-testid="login-form">LoginForm with prefix: {prefix}</div>
|
||||
)
|
||||
}));
|
||||
|
||||
vi.mock('react-router', () => {
|
||||
return {
|
||||
useNavigate: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedUseNavigate = useNavigate as unknown as Mock;
|
||||
|
||||
const authContextMock = {
|
||||
signed: true,
|
||||
user: {
|
||||
userId: 1,
|
||||
name: 'Ricardo',
|
||||
email: 'test@example.com',
|
||||
admin: false,
|
||||
createdAt: new Date(),
|
||||
gravatarImageUrl: 'http://image.com'
|
||||
},
|
||||
checkCurrentAuthUser: vi.fn(),
|
||||
signIn: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
register: vi.fn(),
|
||||
isAdmin: false,
|
||||
updateUser: vi.fn()
|
||||
};
|
||||
|
||||
describe('CompleteResetPassword Component', () => {
|
||||
const mockCheckCurrentAuthUser = vi.fn();
|
||||
const mockNavigate = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedUseNavigate.mockReturnValue(mockNavigate);
|
||||
});
|
||||
|
||||
test('calls checkCurrentAuthUser with the correct pathname', () => {
|
||||
render(
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
...authContextMock,
|
||||
signed: false,
|
||||
checkCurrentAuthUser: mockCheckCurrentAuthUser,
|
||||
}}
|
||||
>
|
||||
<CompleteResetPassword />
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
expect(mockCheckCurrentAuthUser).toHaveBeenCalledWith(window.location.pathname);
|
||||
});
|
||||
|
||||
test('navigates to /home when signed is true', () => {
|
||||
render(
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
...authContextMock,
|
||||
signed: true,
|
||||
checkCurrentAuthUser: mockCheckCurrentAuthUser,
|
||||
}}
|
||||
>
|
||||
<CompleteResetPassword />
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/home');
|
||||
});
|
||||
|
||||
test('renders LoginForm with the correct prefix', () => {
|
||||
render(
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
...authContextMock,
|
||||
signed: false,
|
||||
checkCurrentAuthUser: mockCheckCurrentAuthUser,
|
||||
}}
|
||||
>
|
||||
<CompleteResetPassword />
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
const loginForm = screen.getByTestId('login-form');
|
||||
expect(loginForm.innerHTML.includes('LoginForm with prefix: complete_reset')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
// filepath: /home/ricardo/Projects/react-typescript-todolist/client/src/__test__/views/EmailConfirmation.test.tsx
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { vi, describe, test, expect, beforeEach } from 'vitest';
|
||||
import api from '../../api-service/api';
|
||||
import EmailConfirmation from '../../views/EmailConfirmation';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import ApiConfig from '../../api-service/apiConfig';
|
||||
|
||||
// Mock api.postJSON
|
||||
vi.mock('../../api-service/api');
|
||||
|
||||
const mockedPostJSON = vi.mocked(api.postJSON);
|
||||
|
||||
describe('EmailConfirmation Component', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.history.pushState({}, '', '/?identification=test-id'); // Default query param
|
||||
});
|
||||
|
||||
const renderFn = () => {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<EmailConfirmation />
|
||||
</MemoryRouter>
|
||||
);
|
||||
}
|
||||
|
||||
test('renders loading state initially', () => {
|
||||
render(<EmailConfirmation />);
|
||||
expect(screen.getByText('Confirming your email address...')).toBeDefined();
|
||||
});
|
||||
|
||||
test('shows error when identification is missing', async () => {
|
||||
window.history.pushState({}, '', '/'); // No query param
|
||||
render(<EmailConfirmation />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('❌ Oops!')).toBeDefined();
|
||||
expect(screen.getByText('Wrong or missing identification.')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('shows success message when API call succeeds', async () => {
|
||||
vi.spyOn(api, 'postJSON').mockResolvedValue({});
|
||||
renderFn();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('✅ Email Confirmed')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('shows error message when API call fails', async () => {
|
||||
mockedPostJSON.mockRejectedValueOnce(new Error('API error occurred')); // Mock API failure
|
||||
renderFn();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('❌ Oops!')).toBeDefined();
|
||||
expect(screen.getByText('API error occurred')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('does not call API if identification is missing', async () => {
|
||||
window.history.pushState({}, '', '/'); // No query param
|
||||
renderFn();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedPostJSON).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('calls API with correct payload when identification is present', async () => {
|
||||
mockedPostJSON.mockResolvedValueOnce({}); // Mock successful API response
|
||||
renderFn();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedPostJSON).toHaveBeenCalledWith(ApiConfig.confirmUrl, { identification: 'test-id' });
|
||||
});
|
||||
});
|
||||
|
||||
test('renders correct styles for success state', async () => {
|
||||
mockedPostJSON.mockResolvedValueOnce({}); // Mock successful API response
|
||||
renderFn();
|
||||
|
||||
await waitFor(() => {
|
||||
const successMessage = screen.getByText('✅ Email Confirmed');
|
||||
expect(successMessage).toBeDefined();
|
||||
expect(successMessage.classList.contains('mb-3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders correct styles for error state', async () => {
|
||||
mockedPostJSON.mockRejectedValueOnce(new Error('API error occurred')); // Mock API failure
|
||||
renderFn();
|
||||
|
||||
await waitFor(() => {
|
||||
const errorMessage = screen.getByText('❌ Oops!');
|
||||
expect(errorMessage).toBeDefined();
|
||||
expect(errorMessage.classList.contains('mb-3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('renders "Go to Login" button on success', async () => {
|
||||
mockedPostJSON.mockResolvedValueOnce({}); // Mock successful API response
|
||||
renderFn();
|
||||
|
||||
await waitFor(() => {
|
||||
const loginButton = screen.getByText('Go to Login');
|
||||
expect(loginButton).toBeDefined();
|
||||
expect(loginButton.classList.contains('btn-success')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not render "Go to Login" button on error', async () => {
|
||||
mockedPostJSON.mockRejectedValueOnce(new Error('API error occurred')); // Mock API failure
|
||||
renderFn();
|
||||
|
||||
await waitFor(() => {
|
||||
const loginButton = screen.queryByText('Go to Login');
|
||||
expect(loginButton).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test('shows error message when API call fails', async () => {
|
||||
mockedPostJSON.mockRejectedValueOnce(new Error('API error occurred')); // Mock API failure
|
||||
renderFn();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('❌ Oops!')).toBeDefined();
|
||||
expect(screen.getByText('API error occurred')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,14 @@ const ApiConfig = {
|
||||
|
||||
registerUrl: `${server}/auth/sign-up`,
|
||||
|
||||
confirmUrl: `${server}/auth/email-confirmation`,
|
||||
|
||||
resetPwdUrl: `${server}/auth/password-reset`,
|
||||
|
||||
completeResetPwdUrl: `${server}/auth/complete-password-reset`,
|
||||
|
||||
resendConfirmUrl: `${server}/auth/resend-email-confirmation`,
|
||||
|
||||
refreshTokenUrl: `${server}/rest/user-sessions/refresh`,
|
||||
|
||||
deleteAccountUrl: `${server}/rest/user-sessions/delete-account`,
|
||||
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
Form,
|
||||
Row
|
||||
} from 'react-bootstrap';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AuthContext from '../../context/AuthContext';
|
||||
import { translateServerResponse } from '../../utils/TranslatorUtils';
|
||||
import { handleDefaultLang } from '../../lang-service/LangHandler';
|
||||
import FormInput from '../FormInput';
|
||||
import api from '../../api-service/api';
|
||||
import ApiConfig from '../../api-service/apiConfig';
|
||||
|
||||
/**
|
||||
* @returns {React.ReactNode} The form component for Login and Register pages.
|
||||
@@ -26,9 +29,16 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
const { signIn, register } = useContext(AuthContext);
|
||||
const { i18n, t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [validated, setValidated] = useState<boolean>(false);
|
||||
const [formInvalid, setFormInvalid] = useState<boolean>(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [successMessage, setSuccessMessage] = useState<string>('');
|
||||
const [email, setEmail] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
const [passwordAgain, setPasswordAgain] = useState<string>('');
|
||||
const [secondsLeft, setSecondsLeft] = useState<number>(0);
|
||||
const [isResendEnabled, setIsResendEnabled] = useState(true);
|
||||
|
||||
/**
|
||||
* Navigates to the specified page.
|
||||
@@ -53,19 +63,63 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
const form = event.currentTarget;
|
||||
if (form.checkValidity() === false) {
|
||||
setFormInvalid(true);
|
||||
setErrorMessage(translateServerResponse('Please fill in your username and password!', i18n.language));
|
||||
if (prefix !== 'reset') {
|
||||
setErrorMessage(translateServerResponse('Please fill in your username and password!', i18n.language));
|
||||
}
|
||||
else {
|
||||
setErrorMessage(translateServerResponse('Please fill in your email', i18n.language));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== passwordAgain && prefix === 'register') {
|
||||
setFormInvalid(true);
|
||||
setErrorMessage(translateServerResponse('Please fill in your username and password!', i18n.language));
|
||||
}
|
||||
|
||||
setFormInvalid(false);
|
||||
try {
|
||||
if (prefix === 'login') {
|
||||
await signIn(form.email.value, form.password.value);
|
||||
await signIn(email, password);
|
||||
goTo('/home');
|
||||
}
|
||||
else {
|
||||
await register(form.email.value, form.password.value);
|
||||
else if (prefix === 'register') {
|
||||
await register(email, password, passwordAgain);
|
||||
// Do not clear the email, because user might request to resend
|
||||
setPassword('');
|
||||
setPasswordAgain('');
|
||||
setSuccessMessage('Please confirm your email before proceeding.');
|
||||
setSecondsLeft(30);
|
||||
setIsResendEnabled(false);
|
||||
}
|
||||
goTo('/home');
|
||||
else if (prefix === 'reset') {
|
||||
await api.postJSON(ApiConfig.resetPwdUrl, { email });
|
||||
setSuccessMessage('If the email address you entered is associated with an account, you will receive a password reset link shortly.');
|
||||
}
|
||||
else if (prefix === 'complete_reset') {
|
||||
const token = searchParams.get('token');
|
||||
await api.postJSON(ApiConfig.completeResetPwdUrl, { token, password, passwordAgain });
|
||||
goTo('/home');
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
setFormInvalid(true);
|
||||
if (typeof e === 'string') {
|
||||
setErrorMessage(translateServerResponse(e, i18n.language));
|
||||
}
|
||||
else if (e instanceof Error) {
|
||||
setErrorMessage(translateServerResponse(e.message, i18n.language));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResend = async () => {
|
||||
try {
|
||||
await api.postJSON(ApiConfig.resendConfirmUrl, { email });
|
||||
|
||||
setSuccessMessage('Confirmation email re-sent! Please check the spam folder.');
|
||||
setSecondsLeft(30);
|
||||
setIsResendEnabled(false);
|
||||
}
|
||||
catch (e) {
|
||||
setFormInvalid(true);
|
||||
@@ -82,6 +136,16 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
handleDefaultLang();
|
||||
}, [formInvalid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (secondsLeft > 0) {
|
||||
const timer = setTimeout(() => setSecondsLeft(prev => prev - 1), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
else {
|
||||
setIsResendEnabled(true);
|
||||
}
|
||||
}, [secondsLeft]);
|
||||
|
||||
return (
|
||||
<Container as="main" fluid className="login-page">
|
||||
<Row className="justify-content-center w-100">
|
||||
@@ -98,27 +162,75 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
)
|
||||
: null}
|
||||
|
||||
{successMessage.length > 1 && prefix === 'register' && (
|
||||
<>
|
||||
<Alert variant="success">
|
||||
{ successMessage }
|
||||
</Alert>
|
||||
|
||||
<div className="text-center">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={handleResend}
|
||||
disabled={!isResendEnabled}
|
||||
>
|
||||
{isResendEnabled ? 'Resend Confirmation Email' : `Resend in ${secondsLeft}s`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{successMessage.length > 1 && prefix !== 'register' && (
|
||||
<Alert variant="success">
|
||||
{ successMessage }
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Form noValidate validated={validated} onSubmit={handleSubmit}>
|
||||
<Form.Group className="mb-3" controlId="formBasicEmail">
|
||||
<Form.Label>{t(`${prefix}_email_label`)}</Form.Label>
|
||||
<Form.Control
|
||||
required
|
||||
type="email"
|
||||
{prefix !== 'complete_reset' && (
|
||||
<FormInput
|
||||
labelText="Email"
|
||||
iconName="At"
|
||||
required={true}
|
||||
name="email"
|
||||
placeholder={t(`${prefix}_email_placeholder`)}
|
||||
data-testid={`${prefix}_email_input`}
|
||||
data_testid={`${prefix}_email_input`}
|
||||
value={email}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setEmail(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
|
||||
<Form.Group className="mb-3" controlId="formBasicPassword">
|
||||
<Form.Label>{t(`${prefix}_password_label`)}</Form.Label>
|
||||
<Form.Control
|
||||
required
|
||||
{prefix !== 'reset' && (
|
||||
<FormInput
|
||||
labelText="Password"
|
||||
iconName="Lock"
|
||||
required={true}
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder={t(`${prefix}_password_placeholder`)}
|
||||
value={password}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPassword(e.target.value);
|
||||
}}
|
||||
data_testid="account-password-login"
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
|
||||
{(prefix === 'register' || prefix === 'complete_reset') && (
|
||||
<FormInput
|
||||
labelText="Repeat password"
|
||||
iconName="Lock"
|
||||
required={true}
|
||||
type="password"
|
||||
name="passwordAgain"
|
||||
value={passwordAgain}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPasswordAgain(e.target.value);
|
||||
}}
|
||||
data_testid={`account-repeat-password-${prefix}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -130,12 +242,8 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
</Form>
|
||||
|
||||
<div className="text-center mt-3">
|
||||
{prefix === 'login'
|
||||
? (
|
||||
`${t('login_account')} `
|
||||
)
|
||||
: `${t('register_account')} `}
|
||||
<Link to={prefix === 'login' ? '/register' : '/login'} className="text-decoration-none">
|
||||
{t(`${prefix}_account`)}
|
||||
<Link to={prefix === 'login' ? '/register' : '/login'} className="text-decoration-none ms-2">
|
||||
{t(`${prefix}_go_other`)}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -145,6 +253,13 @@ function LoginForm({ prefix }: { prefix: string }): React.ReactNode {
|
||||
{t(`${prefix}_back_home`)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-center mt-3">
|
||||
{prefix === 'login' && (
|
||||
<Link to="/reset-password" className="text-decoration-none ms-2">
|
||||
Forgot your password?
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
@@ -28,6 +28,24 @@ const enTranslations = {
|
||||
register_go_other: 'Login',
|
||||
register_back_home: 'Back to home',
|
||||
|
||||
reset_title: 'Reset your password',
|
||||
reset_email_label: 'Email',
|
||||
reset_email_placeholder: 'Type your email',
|
||||
reset_password_label: 'Password',
|
||||
reset_password_placeholder: 'Type your password',
|
||||
reset_btn_submit: 'Send confirmation email',
|
||||
reset_account: 'Have you remembered your password?',
|
||||
reset_go_other: 'Login',
|
||||
reset_back_home: 'Back to home',
|
||||
|
||||
complete_reset_title: 'Create your new password',
|
||||
complete_reset_password_label: 'Password',
|
||||
complete_reset_password_placeholder: 'Type your password',
|
||||
complete_reset_btn_submit: 'Reset',
|
||||
complete_reset_account: 'Have you remembered your password?',
|
||||
complete_reset_go_other: 'Login',
|
||||
complete_reset_back_home: 'Back to home',
|
||||
|
||||
home_nav_home: 'Home',
|
||||
home_nav_tasks: 'Tasks',
|
||||
home_nav_notes: 'Notes',
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface AuthContextData {
|
||||
checkCurrentAuthUser: (pathname: string) => Promise<void>;
|
||||
signIn: (email: string, password: string) => Promise<string>;
|
||||
signOut: () => void;
|
||||
register: (email: string, password: string) => Promise<string>;
|
||||
register: (email: string, password: string, passwordAgain: string) => Promise<string>;
|
||||
isAdmin: boolean;
|
||||
updateUser: (userUpdated: UserResponse) => void;
|
||||
}
|
||||
|
||||
@@ -72,22 +72,10 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
}
|
||||
};
|
||||
|
||||
const register = async (email: string, password: string): Promise<string> => {
|
||||
const register = async (email: string, password: string, passwordAgain: string): Promise<string> => {
|
||||
try {
|
||||
const payload = { email, password };
|
||||
const registerResponse: SigninResponse = await api.putJSON(ApiConfig.registerUrl, payload);
|
||||
const currentUser: UserResponse = {
|
||||
userId: registerResponse.userId,
|
||||
name: registerResponse.name,
|
||||
email: registerResponse.email,
|
||||
admin: registerResponse.admin,
|
||||
createdAt: new Date(registerResponse.createdAt),
|
||||
gravatarImageUrl: registerResponse.gravatarImageUrl
|
||||
};
|
||||
|
||||
setSigned(true);
|
||||
setUser(currentUser);
|
||||
updateUserSession(currentUser, registerResponse.token);
|
||||
const payload = { email, password, passwordAgain };
|
||||
await api.putJSON(ApiConfig.registerUrl, payload);
|
||||
return Promise.resolve('OK');
|
||||
}
|
||||
catch (e) {
|
||||
@@ -180,7 +168,7 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }: Pro
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
{ children }
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import LoginForm from '../../components/LoginForm';
|
||||
import AuthContext from '../../context/AuthContext';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
/**
|
||||
* Login page component.
|
||||
*
|
||||
* This component displays the login page of the application,
|
||||
* providing navigation to register or back to home.
|
||||
*
|
||||
* @returns {React.ReactNode} The Login page component.
|
||||
*/
|
||||
function CompleteResetPassword(): React.ReactNode {
|
||||
const { signed, checkCurrentAuthUser } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
checkCurrentAuthUser(window.location.pathname);
|
||||
if (signed) {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [signed]);
|
||||
|
||||
return <LoginForm prefix="complete_reset" />;
|
||||
}
|
||||
|
||||
export default CompleteResetPassword;
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import api from '../../api-service/api';
|
||||
import ApiConfig from '../../api-service/apiConfig';
|
||||
|
||||
type Status = 'loading' | 'success' | 'error';
|
||||
|
||||
const EmailConfirmation: React.FC = () => {
|
||||
const [status, setStatus] = useState<Status>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
const confirmEmail = async (): Promise<void> => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const id = urlParams.get('identification');
|
||||
if (!id) {
|
||||
setStatus('error');
|
||||
setErrorMessage('Wrong or missing identification.');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { identification: id };
|
||||
try {
|
||||
await api.postJSON(ApiConfig.confirmUrl, payload);
|
||||
setStatus('success');
|
||||
}
|
||||
catch (e) {
|
||||
setStatus('error');
|
||||
if (e instanceof Error) {
|
||||
setErrorMessage(e.message);
|
||||
}
|
||||
else {
|
||||
setErrorMessage(e as string);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
confirmEmail();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="d-flex flex-column align-items-center justify-content-center min-vh-100 px-3 text-center"
|
||||
style={{
|
||||
backgroundColor: '#E6F5E9',
|
||||
color: '#212529',
|
||||
fontFamily: 'Poppins, sans-serif'
|
||||
}}
|
||||
>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Poppins&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<div className="p-4 bg-white rounded-3 shadow" style={{ maxWidth: '500px', width: '100%' }}>
|
||||
|
||||
{status === 'loading' && (
|
||||
<>
|
||||
<div className="spinner-border text-success mb-3"></div>
|
||||
<p>Confirming your email address...</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<>
|
||||
<h1 className="mb-3">✅ Email Confirmed</h1>
|
||||
|
||||
<p className="mb-4">
|
||||
Thank you! Your email address has been successfully confirmed.
|
||||
You can now return to the TaskNote app and log in.
|
||||
</p>
|
||||
<Link to="/login" className="btn btn-success px-4 py-2"style={{ fontWeight: 'bold', borderRadius: '6px' }}>
|
||||
Go to Login
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<h1 className="mb-3">❌ Oops!</h1>
|
||||
<p className="mb-4">
|
||||
{ errorMessage }
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailConfirmation;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import LoginForm from '../../components/LoginForm';
|
||||
import AuthContext from '../../context/AuthContext';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
/**
|
||||
* Login page component.
|
||||
*
|
||||
* This component displays the login page of the application,
|
||||
* providing navigation to register or back to home.
|
||||
*
|
||||
* @returns {React.ReactNode} The Login page component.
|
||||
*/
|
||||
function ResetPassword(): React.ReactNode {
|
||||
const { signed, checkCurrentAuthUser } = useContext(AuthContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
checkCurrentAuthUser(window.location.pathname);
|
||||
if (signed) {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [signed]);
|
||||
|
||||
return <LoginForm prefix="reset" />;
|
||||
}
|
||||
|
||||
export default ResetPassword;
|
||||
@@ -2,25 +2,25 @@
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Node 20",
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "./dist/"
|
||||
},
|
||||
"include": [
|
||||
|
||||
@@ -27,6 +27,7 @@ services:
|
||||
CORS_ALLOWED_ORIGINS: http://localhost:5000
|
||||
SERVER_SERVLET_CONTEXT_PATH: /
|
||||
SECURITY_KEY: ${SECURITY_KEY}
|
||||
TARGET_ENV: production
|
||||
ports:
|
||||
- "8585:8585"
|
||||
image: server:candidate
|
||||
|
||||
+2
-3
@@ -32,6 +32,8 @@ services:
|
||||
CORS_ALLOWED_ORIGINS: http://localhost:5000
|
||||
SERVER_SERVLET_CONTEXT_PATH: /
|
||||
SECURITY_KEY: ${SECURITY_KEY}
|
||||
MAILGUN_APIKEY: ${MAILGUN_APIKEY}
|
||||
TARGET_ENV: development
|
||||
ports:
|
||||
- "8585:8585"
|
||||
- "5005:5005"
|
||||
@@ -68,9 +70,6 @@ services:
|
||||
POSTGRES_DB: tasknote
|
||||
POSTGRES_USER: tasknoteuser
|
||||
POSTGRES_PASSWORD: default
|
||||
PGDATA: /tmp
|
||||
volumes:
|
||||
- "./data:/tmp"
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
- make add task button and other text white
|
||||
- remove tasks icons
|
||||
- when a task is done, create another tag #done on it
|
||||
- test multiple tags
|
||||
- improve the calendar icon and due date display
|
||||
- improve styling for rendered markdown
|
||||
+5
-1
@@ -114,6 +114,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OPs & Tools -->
|
||||
<dependency>
|
||||
@@ -353,7 +357,7 @@
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>78%</minimum>
|
||||
<minimum>75%</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
|
||||
+98
-10
@@ -3,7 +3,10 @@ package br.com.tasknoteapp.server.controller;
|
||||
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
|
||||
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
|
||||
import br.com.tasknoteapp.server.exception.UserNotFoundException;
|
||||
import br.com.tasknoteapp.server.request.EmailConfirmationRequest;
|
||||
import br.com.tasknoteapp.server.request.LoginRequest;
|
||||
import br.com.tasknoteapp.server.request.PasswordResetRequest;
|
||||
import br.com.tasknoteapp.server.request.ResendConfirmationRequest;
|
||||
import br.com.tasknoteapp.server.response.UserResponseWithToken;
|
||||
import br.com.tasknoteapp.server.service.AuthService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -14,7 +17,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import java.util.Objects;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
@@ -45,7 +47,7 @@ public class AuthenticationController {
|
||||
summary = "Signup a new user",
|
||||
description = "Signup a new user given his email and password",
|
||||
responses = {
|
||||
@ApiResponse(responseCode = "201", description = "User successfully created and saved"),
|
||||
@ApiResponse(responseCode = "204", description = "User successfully created and saved"),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Wrong or missing information",
|
||||
@@ -55,10 +57,9 @@ public class AuthenticationController {
|
||||
description = "Email already in use",
|
||||
content = @Content(schema = @Schema(implementation = Void.class)))
|
||||
})
|
||||
public ResponseEntity<UserResponseWithToken> signUp(
|
||||
@RequestBody @Valid LoginRequest loginRequest) {
|
||||
UserResponseWithToken response = authService.signUpNewUser(loginRequest);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(response);
|
||||
public ResponseEntity<Void> signUp(@RequestBody @Valid LoginRequest loginRequest) {
|
||||
authService.signUpNewUser(loginRequest);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,10 +80,6 @@ public class AuthenticationController {
|
||||
responseCode = "400",
|
||||
description = "Wrong or missing information",
|
||||
content = @Content(schema = @Schema(implementation = Void.class))),
|
||||
@ApiResponse(
|
||||
responseCode = "401",
|
||||
description = "Unauthorized. Invalid credentials",
|
||||
content = @Content(schema = @Schema(implementation = Void.class))),
|
||||
@ApiResponse(
|
||||
responseCode = "404",
|
||||
description = "User not found",
|
||||
@@ -96,4 +93,95 @@ public class AuthenticationController {
|
||||
}
|
||||
return ResponseEntity.ok().body(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a confirmation email to the user.
|
||||
*
|
||||
* @param confirmation The request containing the user uuid.
|
||||
* @return No content 204 http code.
|
||||
*/
|
||||
@PostMapping(path = "/email-confirmation", consumes = "application/json")
|
||||
@Operation(
|
||||
summary = "Send a confirmation email to the user",
|
||||
description = "After the registration sends the user a confirmation email",
|
||||
responses = {
|
||||
@ApiResponse(responseCode = "204", description = "User successfully logged in"),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Wrong or missing information",
|
||||
content = @Content(schema = @Schema(implementation = Void.class))),
|
||||
})
|
||||
public ResponseEntity<Void> confirmEmailAddress(
|
||||
@RequestBody @Valid EmailConfirmationRequest confirmation) {
|
||||
authService.confirmUserAccount(confirmation.identification());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-Send a confirmation email to the user.
|
||||
*
|
||||
* @param request The request containing user's email.
|
||||
* @return No content 204 http code.
|
||||
*/
|
||||
@PostMapping(path = "/resend-email-confirmation", consumes = "application/json")
|
||||
@Operation(
|
||||
summary = "Re-Send a confirmation email to the user",
|
||||
description = "Allow users to resend the confirmation email",
|
||||
responses = {
|
||||
@ApiResponse(responseCode = "204", description = "User email confirmation resent"),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Wrong or missing information",
|
||||
content = @Content(schema = @Schema(implementation = Void.class))),
|
||||
})
|
||||
public ResponseEntity<Void> resendEmailConfirmation(
|
||||
@RequestBody @Valid ResendConfirmationRequest request) {
|
||||
authService.resendEmailConfirmation(request.email());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a user's password reset.
|
||||
*
|
||||
* @param request The request containing user's email.
|
||||
* @return No content 204 http code.
|
||||
*/
|
||||
@PostMapping(path = "/password-reset", consumes = "application/json")
|
||||
@Operation(
|
||||
summary = "Request a user's password reset",
|
||||
description = "Request the user password reset if there's a user",
|
||||
responses = {
|
||||
@ApiResponse(responseCode = "204", description = "User password requested"),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Wrong or missing information",
|
||||
content = @Content(schema = @Schema(implementation = Void.class))),
|
||||
})
|
||||
public ResponseEntity<Void> passwordReset(@RequestBody @Valid ResendConfirmationRequest request) {
|
||||
authService.resetPasswordForUser(request.email());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the user password change request.
|
||||
*
|
||||
* @param request The request containing the token and the new password.
|
||||
* @return No content 204 http code.
|
||||
*/
|
||||
@PostMapping(path = "/complete-password-reset", consumes = "application/json")
|
||||
@Operation(
|
||||
summary = "Confirm the password reset",
|
||||
description = "Confirm and set the new password for the user",
|
||||
responses = {
|
||||
@ApiResponse(responseCode = "204", description = "User password reset completed"),
|
||||
@ApiResponse(
|
||||
responseCode = "400",
|
||||
description = "Wrong or missing information",
|
||||
content = @Content(schema = @Schema(implementation = Void.class))),
|
||||
})
|
||||
public ResponseEntity<Void> completePasswordReset(
|
||||
@RequestBody @Valid PasswordResetRequest request) {
|
||||
authService.confirmResetPasswordForUser(request);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import jakarta.persistence.Table;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import lombok.Data;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
@@ -45,6 +46,17 @@ public class UserEntity implements UserDetails {
|
||||
@OneToMany(mappedBy = "user")
|
||||
private List<TaskEntity> tasks;
|
||||
|
||||
@Column(name = "email_confirmed_at", nullable = true)
|
||||
private LocalDateTime emailConfirmedAt;
|
||||
|
||||
@Column(name = "email_uuid", columnDefinition = "uuid", nullable = true, unique = true)
|
||||
private UUID emailUuid;
|
||||
|
||||
@Column(name = "reset_password_expiration", nullable = true)
|
||||
private LocalDateTime resetPasswordExpiration;
|
||||
|
||||
@Column(name = "reset_token", nullable = true)
|
||||
private String resetToken;
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
|
||||
@@ -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 exception represents an error when hashing. */
|
||||
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
public class BadAlgorithmException extends ResponseStatusException {
|
||||
|
||||
public BadAlgorithmException(String error) {
|
||||
super(HttpStatus.SERVICE_UNAVAILABLE, error);
|
||||
}
|
||||
}
|
||||
@@ -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 request when trying to convert to UUID. */
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
public class BadUuidException extends ResponseStatusException {
|
||||
|
||||
public BadUuidException() {
|
||||
super(HttpStatus.BAD_REQUEST, "Bad user identification");
|
||||
}
|
||||
}
|
||||
@@ -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 exception represents an error when sending email messages. */
|
||||
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
public class MailServiceException extends ResponseStatusException {
|
||||
|
||||
public MailServiceException(String error) {
|
||||
super(HttpStatus.SERVICE_UNAVAILABLE, error);
|
||||
}
|
||||
}
|
||||
@@ -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 Reset expired request. */
|
||||
@ResponseStatus(code = HttpStatus.BAD_REQUEST)
|
||||
public class ResetExpiredException extends ResponseStatusException {
|
||||
|
||||
public ResetExpiredException() {
|
||||
super(HttpStatus.NOT_FOUND, "Expired reset link.");
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,15 @@ package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
/** This interface contains methods to access the user table in the database. */
|
||||
public interface UserRepository extends JpaRepository<UserEntity, Long> {
|
||||
|
||||
Optional<UserEntity> findByEmail(String email);
|
||||
|
||||
Optional<UserEntity> findByEmailUuid(UUID uuid);
|
||||
|
||||
Optional<UserEntity> findByResetToken(String token);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** This record represents a confirmation payload. */
|
||||
@Schema(description = "Confirmation payload for the user to confirm his email account.")
|
||||
public record EmailConfirmationRequest(
|
||||
@Schema(description = "Confirmation token") @NotNull String identification) {}
|
||||
@@ -25,6 +25,9 @@ public class LoginRequest {
|
||||
@NotNull
|
||||
String password;
|
||||
|
||||
@Schema(description = "User password again.")
|
||||
String passwordAgain;
|
||||
|
||||
public String email() {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
@@ -32,4 +35,8 @@ public class LoginRequest {
|
||||
public String password() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public String passwordAgain() {
|
||||
return passwordAgain;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/** This record represents the confirmation of the password reset. */
|
||||
@Schema(description = "The password request confirmation payload.")
|
||||
public record PasswordResetRequest(
|
||||
@Schema(description = "Reset token") @NotNull String token,
|
||||
@Schema(description = "New password") @NotNull String password,
|
||||
@Schema(description = "New password again") @NotNull String passwordAgain) {}
|
||||
@@ -0,0 +1,27 @@
|
||||
package br.com.tasknoteapp.server.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
/** This class represents a login request with user email and password. */
|
||||
@Schema(description = "Resend confirmation request with user email and password.")
|
||||
@Setter
|
||||
@NotNull
|
||||
@EqualsAndHashCode
|
||||
@ToString
|
||||
@AllArgsConstructor
|
||||
public class ResendConfirmationRequest {
|
||||
@Schema(description = "User email.")
|
||||
@Email
|
||||
@NotNull
|
||||
String email;
|
||||
|
||||
public String email() {
|
||||
return email.trim().toLowerCase();
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,23 @@ 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.BadPasswordException;
|
||||
import br.com.tasknoteapp.server.exception.BadUuidException;
|
||||
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
|
||||
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
|
||||
import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException;
|
||||
import br.com.tasknoteapp.server.exception.ResetExpiredException;
|
||||
import br.com.tasknoteapp.server.exception.UserForbiddenException;
|
||||
import br.com.tasknoteapp.server.exception.UserNotFoundException;
|
||||
import br.com.tasknoteapp.server.repository.UserPwdLimitRepository;
|
||||
import br.com.tasknoteapp.server.repository.UserRepository;
|
||||
import br.com.tasknoteapp.server.request.LoginRequest;
|
||||
import br.com.tasknoteapp.server.request.PasswordResetRequest;
|
||||
import br.com.tasknoteapp.server.request.UserPatchRequest;
|
||||
import br.com.tasknoteapp.server.response.UserResponse;
|
||||
import br.com.tasknoteapp.server.response.UserResponseWithToken;
|
||||
import br.com.tasknoteapp.server.util.AuthUtil;
|
||||
import br.com.tasknoteapp.server.util.TokenUtil;
|
||||
import br.com.tasknoteapp.server.util.UuidUtil;
|
||||
import jakarta.transaction.Transactional;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
@@ -25,6 +30,7 @@ import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Sort;
|
||||
@@ -54,12 +60,15 @@ public class AuthService {
|
||||
|
||||
private final UserPwdLimitRepository userPwdLimitRepository;
|
||||
|
||||
private final MailgunEmailService mailgunEmailService;
|
||||
|
||||
/**
|
||||
* Create a new user in the app.
|
||||
*
|
||||
* @param login User details with email and password.
|
||||
* @return Token
|
||||
*/
|
||||
@Transactional
|
||||
public UserResponseWithToken signUpNewUser(LoginRequest login) {
|
||||
log.info("Signing up new user! {}", login.email());
|
||||
|
||||
@@ -72,17 +81,24 @@ public class AuthService {
|
||||
throw new BadPasswordException(passwordValidation.get());
|
||||
}
|
||||
|
||||
if (Objects.isNull(login.passwordAgain()) || !login.password().equals(login.passwordAgain())) {
|
||||
throw new BadPasswordException("The passwords should match");
|
||||
}
|
||||
|
||||
UUID emailUuid = new UuidUtil().generateEmailUuid(login.email());
|
||||
|
||||
UserEntity user = new UserEntity();
|
||||
user.setEmail(login.email());
|
||||
user.setPassword(passwordEncoder.encode(login.password()));
|
||||
user.setAdmin(login.email().equals("ricardompcampos@gmail.com"));
|
||||
user.setCreatedAt(LocalDateTime.now());
|
||||
user.setEmailUuid(emailUuid);
|
||||
userRepository.save(user);
|
||||
|
||||
String token = jwtService.generateToken(user);
|
||||
mailgunEmailService.sendNewUser(user);
|
||||
|
||||
log.info("User created! ID {}", user.getId());
|
||||
return UserResponseWithToken.fromEntity(user, token, getGravatarImageUrl(login.email()));
|
||||
return UserResponseWithToken.fromEntity(user, null, getGravatarImageUrl(login.email()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,31 +136,35 @@ public class AuthService {
|
||||
public UserResponseWithToken signInUser(LoginRequest login) {
|
||||
log.info("Signing in user! {}", login.email());
|
||||
|
||||
Optional<UserEntity> user = findByEmail(login.email());
|
||||
if (user.isEmpty()) {
|
||||
Optional<UserEntity> userOptional = findByEmail(login.email());
|
||||
if (userOptional.isEmpty()) {
|
||||
throw new InvalidCredentialsException();
|
||||
}
|
||||
|
||||
checkLoginAttemptLimit(user.get().getId());
|
||||
checkLoginAttemptLimit(userOptional.get().getId());
|
||||
|
||||
UserEntity user = userOptional.get();
|
||||
user.setResetToken(null);
|
||||
user.setResetPasswordExpiration(null);
|
||||
|
||||
try {
|
||||
authenticationManager.authenticate(
|
||||
new UsernamePasswordAuthenticationToken(login.email(), login.password()));
|
||||
|
||||
String token = jwtService.generateToken(user.get());
|
||||
String token = jwtService.generateToken(user);
|
||||
|
||||
log.info("User authenticated! Token {}", token);
|
||||
|
||||
userPwdLimitRepository.deleteAllForUser(user.get().getId());
|
||||
return UserResponseWithToken.fromEntity(
|
||||
user.get(), token, getGravatarImageUrl(login.email()));
|
||||
userPwdLimitRepository.deleteAllForUser(user.getId());
|
||||
userRepository.save(user);
|
||||
return UserResponseWithToken.fromEntity(user, token, getGravatarImageUrl(login.email()));
|
||||
} catch (BadCredentialsException e) {
|
||||
log.error("BadCredentialsException when logging in user {}", user.get().getId());
|
||||
log.error("BadCredentialsException when logging in user {}", user.getId());
|
||||
|
||||
// store attempt
|
||||
UserPwdLimitEntity pwdLimit = new UserPwdLimitEntity();
|
||||
pwdLimit.setWhenHappened(LocalDateTime.now());
|
||||
pwdLimit.setUser(user.get());
|
||||
pwdLimit.setUser(user);
|
||||
userPwdLimitRepository.save(pwdLimit);
|
||||
|
||||
return null;
|
||||
@@ -258,6 +278,10 @@ public class AuthService {
|
||||
throw new BadPasswordException(passwordValidation.get());
|
||||
}
|
||||
|
||||
if (!patchRequest.password().equals(patchRequest.passwordAgain())) {
|
||||
throw new BadPasswordException("The passwords should match");
|
||||
}
|
||||
|
||||
currentUser.setPassword(passwordEncoder.encode(patchRequest.password()));
|
||||
shouldUpdate = true;
|
||||
}
|
||||
@@ -284,6 +308,123 @@ public class AuthService {
|
||||
return findByEmail(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a user account.
|
||||
*
|
||||
* @param identification The UUID generated when registering.
|
||||
* @throws BadUuidException if bad identification
|
||||
*/
|
||||
@Transactional
|
||||
public void confirmUserAccount(String identification) {
|
||||
log.info("Confirming user email account");
|
||||
UUID uuid = null;
|
||||
|
||||
try {
|
||||
uuid = UUID.fromString(identification);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
throw new BadUuidException();
|
||||
}
|
||||
|
||||
Optional<UserEntity> userOptional = userRepository.findByEmailUuid(uuid);
|
||||
if (userOptional.isEmpty()) {
|
||||
throw new UserNotFoundException();
|
||||
}
|
||||
|
||||
UserEntity user = userOptional.get();
|
||||
user.setEmailConfirmedAt(LocalDateTime.now());
|
||||
|
||||
userRepository.save(user);
|
||||
log.info("User email address confirmed: {}", identification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-send the confirm email to the user.
|
||||
*
|
||||
* @param email The email to re-send.
|
||||
*/
|
||||
public void resendEmailConfirmation(String email) {
|
||||
log.info("Re-sending the confirmation email");
|
||||
|
||||
Optional<UserEntity> userOptional = userRepository.findByEmail(email);
|
||||
if (userOptional.isEmpty()) {
|
||||
throw new UserNotFoundException();
|
||||
}
|
||||
|
||||
UserEntity user = userOptional.get();
|
||||
|
||||
mailgunEmailService.sendNewUser(user);
|
||||
|
||||
log.info("Confirmation email re-sent!");
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the password reset for the user.
|
||||
*
|
||||
* @param email The user email.
|
||||
*/
|
||||
@Transactional
|
||||
public void resetPasswordForUser(String email) {
|
||||
log.info("Requesting password reset for email {}", email);
|
||||
|
||||
Optional<UserEntity> userOptional = userRepository.findByEmail(email);
|
||||
if (userOptional.isEmpty()) {
|
||||
log.info("No user found with this email {}", email);
|
||||
return;
|
||||
}
|
||||
|
||||
String resetToken = new TokenUtil().generateToken();
|
||||
|
||||
UserEntity user = userOptional.get();
|
||||
user.setResetToken(resetToken);
|
||||
user.setResetPasswordExpiration(LocalDateTime.now().plusHours(2L));
|
||||
|
||||
userRepository.save(user);
|
||||
mailgunEmailService.sendResetPassword(user);
|
||||
|
||||
log.info("Password reset request succeeded");
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the password reset and recreate the password for the user.
|
||||
*
|
||||
* @param request The token and new passwords.
|
||||
*/
|
||||
@Transactional
|
||||
public void confirmResetPasswordForUser(PasswordResetRequest request) {
|
||||
log.info("Saving new password for token {}", request.token());
|
||||
|
||||
Optional<UserEntity> userOptional = userRepository.findByResetToken(request.token());
|
||||
if (userOptional.isEmpty()) {
|
||||
throw new UserNotFoundException();
|
||||
}
|
||||
|
||||
LocalDateTime requestTime = userOptional.get().getResetPasswordExpiration();
|
||||
boolean isMoreThan2Hours =
|
||||
Duration.between(LocalDateTime.now(), requestTime).abs().toHours() > 2;
|
||||
if (isMoreThan2Hours) {
|
||||
throw new ResetExpiredException();
|
||||
}
|
||||
|
||||
Optional<String> passwordValidation = authUtil.validatePassword(request.password());
|
||||
if (passwordValidation.isPresent()) {
|
||||
throw new BadPasswordException(passwordValidation.get());
|
||||
}
|
||||
|
||||
if (!request.password().equals(request.passwordAgain())) {
|
||||
throw new BadPasswordException("The passwords should match");
|
||||
}
|
||||
|
||||
UserEntity user = userOptional.get();
|
||||
user.setResetToken(null);
|
||||
user.setResetPasswordExpiration(null);
|
||||
user.setPassword(passwordEncoder.encode(request.password()));
|
||||
|
||||
userRepository.save(user);
|
||||
mailgunEmailService.sendPasswordResetConfirmation(user);
|
||||
|
||||
log.info("New password set for token {}", request.token());
|
||||
}
|
||||
|
||||
private Optional<String> getGravatarImageUrl(String email) {
|
||||
email = email.toLowerCase().trim();
|
||||
log.info("Current user email: {}", email);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
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.MailgunTemplateResetPwd;
|
||||
import br.com.tasknoteapp.server.templates.MailgunTemplateResetPwdConfirm;
|
||||
import br.com.tasknoteapp.server.templates.MailgunTemplateSignUp;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.LinkedMultiValueMap;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/** This service handles email messages for Mailgun. */
|
||||
@Slf4j
|
||||
@Service
|
||||
public class MailgunEmailService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final String targetEnv;
|
||||
private String domain;
|
||||
private String senderEmail;
|
||||
|
||||
/**
|
||||
* Creates an instance of the Mail service class.
|
||||
*
|
||||
* @param apiKey The api key to send emails with.
|
||||
* @param domain The domain to send email from.
|
||||
* @param sender The from option.
|
||||
* @param targetEnv The environment.
|
||||
* @param templateBuilder The template builder.
|
||||
*/
|
||||
public MailgunEmailService(
|
||||
@Value("${mailgun.api-key}") String apiKey,
|
||||
@Value("${mailgun.domain}") String domain,
|
||||
@Value("${mailgun.sender-email}") String sender,
|
||||
@Value("${br.com.tasknote.server.target-env}") String targetEnv,
|
||||
RestTemplateBuilder templateBuilder) {
|
||||
this.domain = domain;
|
||||
this.senderEmail = sender;
|
||||
this.targetEnv = targetEnv;
|
||||
this.restTemplate =
|
||||
templateBuilder.defaultHeader(HttpHeaders.AUTHORIZATION, basicAuth("api", apiKey)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send new users a message confirming their account.
|
||||
*
|
||||
* @param user The user that should be addressed the message.
|
||||
*/
|
||||
public void sendNewUser(UserEntity user) {
|
||||
log.info("Sending message confirming user email address.");
|
||||
|
||||
String to = user.getEmail();
|
||||
String subject = "TaskNote App confirmation email";
|
||||
String link = getBaseUrl() + "/email-confirmation?identification=%s";
|
||||
|
||||
MailgunTemplateSignUp signUpTemplate = new MailgunTemplateSignUp();
|
||||
signUpTemplate.setConfirmationLink(String.format(link, user.getEmailUuid().toString()));
|
||||
|
||||
sendEmail(to, subject, signUpTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a password reset link.
|
||||
*
|
||||
* @param user The user that should be addressed the message.
|
||||
*/
|
||||
public void sendResetPassword(UserEntity user) {
|
||||
log.info("Sending message with password reset link");
|
||||
|
||||
String to = user.getEmail();
|
||||
String subject = "TaskNote App password reset";
|
||||
String link = getBaseUrl() + "/finish-reset-password?token=%s";
|
||||
|
||||
MailgunTemplateResetPwd resetTemplate = new MailgunTemplateResetPwd();
|
||||
resetTemplate.setResetLink(String.format(link, user.getResetToken()));
|
||||
|
||||
sendEmail(to, subject, resetTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a confirmation for the password change.
|
||||
*
|
||||
* @param user The user that should be addressed the message.
|
||||
*/
|
||||
public void sendPasswordResetConfirmation(UserEntity user) {
|
||||
log.info("Sending message with password reset confirmation");
|
||||
|
||||
String to = user.getEmail();
|
||||
String subject = "TaskNote App password confirmation";
|
||||
|
||||
MailgunTemplateResetPwdConfirm resetTemplate = new MailgunTemplateResetPwdConfirm();
|
||||
|
||||
sendEmail(to, subject, resetTemplate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email message.
|
||||
*
|
||||
* @param to The target email address.
|
||||
* @param subject The message subject.
|
||||
* @param textBody The message text body to be displayed.
|
||||
* @param htmlBody The message html body to be rendered.
|
||||
*/
|
||||
private void sendEmail(String to, String subject, MailgunTemplate template) {
|
||||
String url = "https://api.mailgun.net/v3/" + domain + "/messages";
|
||||
String from = "TaskNote App <" + senderEmail + ">";
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
|
||||
|
||||
MultiValueMap<String, String> mailData = new LinkedMultiValueMap<>();
|
||||
mailData.add("from", from);
|
||||
mailData.add("to", to);
|
||||
mailData.add("subject", subject);
|
||||
mailData.add("template", template.getName());
|
||||
if (!template.getVariables().isEmpty()) {
|
||||
mailData.add("h:X-Mailgun-Variables", template.getVariableValuesJson());
|
||||
log.info("JSON template variables: {}", template.getVariableValuesJson());
|
||||
}
|
||||
|
||||
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(mailData, headers);
|
||||
|
||||
try {
|
||||
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);
|
||||
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
throw new MailServiceException("Failed to send email: " + response.getStatusCode());
|
||||
}
|
||||
|
||||
log.info("Email message send successfully.");
|
||||
} catch (HttpClientErrorException ex) {
|
||||
log.error("Unable to send email: {} - {}", ex.getMessage(), ex.getCause());
|
||||
}
|
||||
}
|
||||
|
||||
private String basicAuth(String username, String password) {
|
||||
String auth = username + ":" + password;
|
||||
return "Basic " + Base64.getEncoder().encodeToString(auth.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private String getBaseUrl() {
|
||||
if ("development".equals(targetEnv) || Objects.isNull(targetEnv)) {
|
||||
return "http://localhost:5000";
|
||||
}
|
||||
String stage = targetEnv.equals("stage") ? "stage." : "";
|
||||
return String.format("https://%s%s", stage, domain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package br.com.tasknoteapp.server.templates;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/** This interface represents a mailgun template structure. */
|
||||
public interface MailgunTemplate {
|
||||
|
||||
static final String STRING_SCAPE = "\"";
|
||||
static final String COLON = ":";
|
||||
static final String COMMA = ",";
|
||||
|
||||
String getName();
|
||||
|
||||
Map<String, Object> getVariables();
|
||||
|
||||
/**
|
||||
* Default method to get variables in JSON format.
|
||||
*
|
||||
* @return The JSON String representation.
|
||||
*/
|
||||
default String getVariableValuesJson() {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
for (Map.Entry<String, Object> entry : getVariables().entrySet()) {
|
||||
if (sb.toString().length() > 1) {
|
||||
sb.append(COMMA);
|
||||
}
|
||||
sb.append(STRING_SCAPE).append(entry.getKey()).append(STRING_SCAPE);
|
||||
sb.append(COLON);
|
||||
sb.append(STRING_SCAPE).append(entry.getValue().toString()).append(STRING_SCAPE);
|
||||
}
|
||||
sb.append("}");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package br.com.tasknoteapp.server.templates;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** This class represents a template for the password reset workflow. */
|
||||
public class MailgunTemplateResetPwd implements MailgunTemplate {
|
||||
|
||||
private String templateName = "password reset";
|
||||
private final Map<String, Object> props;
|
||||
|
||||
public MailgunTemplateResetPwd() {
|
||||
this.props = new HashMap<>();
|
||||
}
|
||||
|
||||
public void setResetLink(String resetLink) {
|
||||
props.put("RESET_LINK", resetLink);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return templateName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getVariables() {
|
||||
return props;
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package br.com.tasknoteapp.server.templates;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** This class represents a template for the password reset confirmation workflow. */
|
||||
public class MailgunTemplateResetPwdConfirm implements MailgunTemplate {
|
||||
|
||||
private String templateName = "password change confirmation";
|
||||
private final Map<String, Object> props;
|
||||
|
||||
public MailgunTemplateResetPwdConfirm() {
|
||||
this.props = new HashMap<>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return templateName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getVariables() {
|
||||
return props;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package br.com.tasknoteapp.server.templates;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** This class represents a template for the sign up workflow. */
|
||||
public class MailgunTemplateSignUp implements MailgunTemplate {
|
||||
|
||||
private String templateName = "sign up confirmation";
|
||||
private final Map<String, Object> props;
|
||||
|
||||
public MailgunTemplateSignUp() {
|
||||
this.props = new HashMap<>();
|
||||
}
|
||||
|
||||
public void setConfirmationLink(String confirmationLink) {
|
||||
props.put("CONFIRMATION_LINK", confirmationLink);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return templateName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> getVariables() {
|
||||
return props;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package br.com.tasknoteapp.server.util;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/** This class contains a method to create token for the password reset. */
|
||||
public class TokenUtil {
|
||||
private static final SecureRandom secureRandom = new SecureRandom();
|
||||
private static final Base64.Encoder base64Encoder = Base64.getUrlEncoder().withoutPadding();
|
||||
|
||||
/**
|
||||
* Generate a token for the user reset his password.
|
||||
*
|
||||
* @return The encoded token with 24 bytes.
|
||||
*/
|
||||
public String generateToken() {
|
||||
// 24 bytes ~ 32 chars length after Base64
|
||||
// Base64 encoding expands that by a factor of 4/3.
|
||||
// 24 bytes * 4/3 = 32 characters
|
||||
byte[] randomBytes = new byte[24];
|
||||
secureRandom.nextBytes(randomBytes);
|
||||
return base64Encoder.encodeToString(randomBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package br.com.tasknoteapp.server.util;
|
||||
|
||||
import br.com.tasknoteapp.server.exception.BadAlgorithmException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
|
||||
/** This class provides method to handle UUIDs. */
|
||||
public class UuidUtil {
|
||||
private final UUID namespaceUrl = UUID.fromString("6ba7b811-9dad-11d1-80b4-00c04fd430c8");
|
||||
|
||||
/**
|
||||
* Generated a unique UUID to a given email.
|
||||
*
|
||||
* @param email The email to create the UUID.
|
||||
* @return The generated UUID.
|
||||
*/
|
||||
public UUID generateEmailUuid(String email) {
|
||||
return generateUuidFromName(namespaceUrl, email.toLowerCase().trim());
|
||||
}
|
||||
|
||||
private UUID generateUuidFromName(UUID namespace, String name) {
|
||||
// SHA-1 digest of namespace UUID + name
|
||||
byte[] namespaceBytes = toBytes(namespace);
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
byte[] combined = new byte[namespaceBytes.length + nameBytes.length];
|
||||
System.arraycopy(namespaceBytes, 0, combined, 0, namespaceBytes.length);
|
||||
System.arraycopy(nameBytes, 0, combined, namespaceBytes.length, nameBytes.length);
|
||||
|
||||
byte[] sha1 = sha1(combined);
|
||||
|
||||
// Manipulate bits to make it UUID v5 (version 5, SHA-1)
|
||||
sha1[6] &= 0x0f;
|
||||
sha1[6] |= 0x50;
|
||||
sha1[8] &= 0x3f;
|
||||
sha1[8] |= 0x80;
|
||||
|
||||
return bytesToUuid(sha1);
|
||||
}
|
||||
|
||||
private byte[] toBytes(UUID uuid) {
|
||||
long msb = uuid.getMostSignificantBits();
|
||||
long lsb = uuid.getLeastSignificantBits();
|
||||
byte[] bytes = new byte[16];
|
||||
|
||||
for (int i = 0; i < 8; i++) {
|
||||
bytes[i] = (byte) ((msb >>> (8 * (7 - i))) & 0xFF);
|
||||
bytes[8 + i] = (byte) ((lsb >>> (8 * (7 - i))) & 0xFF);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private byte[] sha1(byte[] input) {
|
||||
try {
|
||||
return java.security.MessageDigest.getInstance("SHA-1").digest(input);
|
||||
} catch (Exception e) {
|
||||
throw new BadAlgorithmException("SHA-1 algorithm not available");
|
||||
}
|
||||
}
|
||||
|
||||
private UUID bytesToUuid(byte[] hash) {
|
||||
long msb = 0;
|
||||
long lsb = 0;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
msb = (msb << 8) | (hash[i] & 0xff);
|
||||
}
|
||||
for (int i = 8; i < 16; i++) {
|
||||
lsb = (lsb << 8) | (hash[i] & 0xff);
|
||||
}
|
||||
return new UUID(msb, lsb);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ br:
|
||||
tasknote:
|
||||
server:
|
||||
jwt-secret: ${SECURITY_KEY:empty}
|
||||
target-env: ${TARGET_ENV:development}
|
||||
version: ${BUILD:local}
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost}
|
||||
@@ -10,6 +11,11 @@ logging:
|
||||
level:
|
||||
root: ${ROOT_LOG_LEVEL:INFO}
|
||||
|
||||
mailgun:
|
||||
api-key: ${MAILGUN_APIKEY:abc123456}
|
||||
domain: tasknoteapp.dev.br
|
||||
sender-email: no-reply@tasknoteapp.dev.br
|
||||
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE tasknote.users
|
||||
ADD email_confirmed_at TIMESTAMP null,
|
||||
ADD email_uuid UUID UNIQUE NULL,
|
||||
ADD reset_password_expiration TIMESTAMP NULL,
|
||||
ADD reset_token VARCHAR(35) NULL;
|
||||
+14
-14
@@ -32,7 +32,7 @@ class AuthenticationControllerTest {
|
||||
@Test
|
||||
@DisplayName("Sign up happy path should succeed")
|
||||
void signup_happyPath_shouldSucceed() throws Exception {
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456");
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
|
||||
final String token = "xaxbxcxdx1x2x3A@";
|
||||
|
||||
UserResponseWithToken response =
|
||||
@@ -44,7 +44,8 @@ class AuthenticationControllerTest {
|
||||
"""
|
||||
{
|
||||
"email": "user@domain.com",
|
||||
"password": "abcde123456"
|
||||
"password": "abcde123456",
|
||||
"passwordAgain": "abcde123456"
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -55,18 +56,14 @@ class AuthenticationControllerTest {
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.content(jsonString))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.userId").value(response.userId()))
|
||||
.andExpect(jsonPath("$.email").value(response.email()))
|
||||
.andExpect(jsonPath("$.admin").value(response.admin()))
|
||||
.andExpect(jsonPath("$.token").value(token))
|
||||
.andExpect(status().isNoContent())
|
||||
.andReturn();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Sign up bad email request should fail")
|
||||
void signup_badEmailRequest_shouldFail() throws Exception {
|
||||
LoginRequest request = new LoginRequest("user@domain..com", "abcde123456");
|
||||
LoginRequest request = new LoginRequest("user@domain..com", "abcde123456", "abcde123456");
|
||||
final String token = "xaxbxcxdx1x2x3@A";
|
||||
|
||||
UserResponseWithToken response =
|
||||
@@ -78,7 +75,8 @@ class AuthenticationControllerTest {
|
||||
"""
|
||||
{
|
||||
"email": "user@domain..com",
|
||||
"password": "abcde123456"
|
||||
"password": "abcde123456",
|
||||
"passwordAgain": "abcde123456"
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -96,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");
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
|
||||
|
||||
when(authService.signUpNewUser(request)).thenThrow(new EmailAlreadyExistsException());
|
||||
|
||||
@@ -104,7 +102,8 @@ class AuthenticationControllerTest {
|
||||
"""
|
||||
{
|
||||
"email": "user@domain.com",
|
||||
"password": "abcde123456"
|
||||
"password": "abcde123456",
|
||||
"passwordAgain": "abcde123456"
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -122,7 +121,7 @@ class AuthenticationControllerTest {
|
||||
@Test
|
||||
@DisplayName("Sign in happy path should succeed")
|
||||
void signin_happyPath_shouldSucceed() throws Exception {
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456");
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
|
||||
final String token = "xaxbxcxdx1x2x3A@";
|
||||
|
||||
UserResponseWithToken response =
|
||||
@@ -134,7 +133,8 @@ class AuthenticationControllerTest {
|
||||
"""
|
||||
{
|
||||
"email": "user@domain.com",
|
||||
"password": "abcde123456"
|
||||
"password": "abcde123456",
|
||||
"passwordAgain": "abcde123456"
|
||||
}
|
||||
""";
|
||||
|
||||
@@ -156,7 +156,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");
|
||||
LoginRequest request = new LoginRequest("user@domain.com", "abcde123456", "abcde123456");
|
||||
|
||||
when(authService.signInUser(request)).thenReturn(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package br.com.tasknoteapp.server.repository;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
|
||||
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
|
||||
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
|
||||
import org.springframework.test.context.jdbc.Sql;
|
||||
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = Replace.NONE)
|
||||
@Sql(scripts = {"classpath:sql/UserRepositoryTest.sql"})
|
||||
class UserRepositoryIntTest {
|
||||
|
||||
@Autowired UserRepository userRepository;
|
||||
|
||||
private static final String UUID_CODE = "cc2b5506-83ed-5764-985e-611ad4ce8050";
|
||||
|
||||
@Test
|
||||
void findByEmailUuid_happyPath_shouldSucceed() {
|
||||
UUID uuid = UUID.fromString(UUID_CODE);
|
||||
Optional<UserEntity> user = userRepository.findByEmailUuid(uuid);
|
||||
|
||||
Assertions.assertFalse(user.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void findByResetToken_happyPath_shouldSucceed() {
|
||||
String token = "abc123456";
|
||||
Optional<UserEntity> user = userRepository.findByResetToken(token);
|
||||
|
||||
Assertions.assertFalse(user.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,17 @@ import static org.mockito.Mockito.when;
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import br.com.tasknoteapp.server.entity.UserPwdLimitEntity;
|
||||
import br.com.tasknoteapp.server.exception.BadPasswordException;
|
||||
import br.com.tasknoteapp.server.exception.BadUuidException;
|
||||
import br.com.tasknoteapp.server.exception.EmailAlreadyExistsException;
|
||||
import br.com.tasknoteapp.server.exception.InvalidCredentialsException;
|
||||
import br.com.tasknoteapp.server.exception.MaxLoginLimitAttemptException;
|
||||
import br.com.tasknoteapp.server.exception.ResetExpiredException;
|
||||
import br.com.tasknoteapp.server.exception.UserForbiddenException;
|
||||
import br.com.tasknoteapp.server.exception.UserNotFoundException;
|
||||
import br.com.tasknoteapp.server.repository.UserPwdLimitRepository;
|
||||
import br.com.tasknoteapp.server.repository.UserRepository;
|
||||
import br.com.tasknoteapp.server.request.LoginRequest;
|
||||
import br.com.tasknoteapp.server.request.PasswordResetRequest;
|
||||
import br.com.tasknoteapp.server.request.UserPatchRequest;
|
||||
import br.com.tasknoteapp.server.response.UserResponse;
|
||||
import br.com.tasknoteapp.server.response.UserResponseWithToken;
|
||||
@@ -24,6 +27,7 @@ import br.com.tasknoteapp.server.util.AuthUtil;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
@@ -53,6 +57,8 @@ class AuthServiceTest {
|
||||
|
||||
@Mock private UserPwdLimitRepository userPwdLimitRepository;
|
||||
|
||||
@Mock private MailgunEmailService mailgunEmailService;
|
||||
|
||||
private AuthService authService;
|
||||
|
||||
@BeforeEach
|
||||
@@ -64,13 +70,14 @@ class AuthServiceTest {
|
||||
jwtService,
|
||||
authenticationManager,
|
||||
authUtil,
|
||||
userPwdLimitRepository);
|
||||
userPwdLimitRepository,
|
||||
mailgunEmailService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SignUp new user happy path should succeed")
|
||||
void signUpNewUser_happyPath_shouldSucceed() {
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!");
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!", "123456@abcde!");
|
||||
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty());
|
||||
when(authUtil.validatePassword(request.password())).thenReturn(Optional.empty());
|
||||
@@ -79,22 +86,24 @@ class AuthServiceTest {
|
||||
entity.setId(3L);
|
||||
entity.setEmail(request.email());
|
||||
entity.setName("User");
|
||||
entity.setEmailUuid(UUID.randomUUID());
|
||||
|
||||
when(userRepository.save(any())).thenReturn(entity);
|
||||
when(jwtService.generateToken(any())).thenReturn("a1b2c3");
|
||||
doNothing().when(mailgunEmailService).sendNewUser(any());
|
||||
|
||||
UserResponseWithToken token = authService.signUpNewUser(request);
|
||||
|
||||
Assertions.assertNotNull(token);
|
||||
Assertions.assertFalse(token.token().isBlank());
|
||||
Assertions.assertEquals("a1b2c3", token.token());
|
||||
Assertions.assertNull(token.token());
|
||||
Assertions.assertEquals(entity.getEmail(), token.email());
|
||||
Assertions.assertNotNull(entity.getEmailUuid());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SignUp new user with existing email should fail")
|
||||
void signUpNewUser_emailExists_shouldFail() {
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!");
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456@abcde!", "123456@abcde!");
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.of(existing));
|
||||
@@ -109,7 +118,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");
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
|
||||
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty());
|
||||
when(authUtil.validatePassword(request.password())).thenReturn(Optional.of("Bad password"));
|
||||
@@ -181,7 +190,7 @@ class AuthServiceTest {
|
||||
@Test
|
||||
@DisplayName("SignIn user happy path should succeed")
|
||||
void signInUser_happyPath_shouldSucceed() {
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456");
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
existing.setId(919L);
|
||||
@@ -204,7 +213,7 @@ class AuthServiceTest {
|
||||
@Test
|
||||
@DisplayName("SignIn wrong user or password should fail")
|
||||
void signInUser_wrongUserOrPassword_shouldFail() {
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456");
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
|
||||
|
||||
when(userRepository.findByEmail(request.email())).thenReturn(Optional.empty());
|
||||
|
||||
@@ -218,7 +227,7 @@ class AuthServiceTest {
|
||||
@Test
|
||||
@DisplayName("SignIn max login attempt should fail")
|
||||
void signInUser_maxLoginAttempt_shouldFail() {
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456");
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
existing.setId(919L);
|
||||
@@ -242,7 +251,7 @@ class AuthServiceTest {
|
||||
@Test
|
||||
@DisplayName("SignIn bad credentials should fail")
|
||||
void signInUser_badCredentials_shouldFail() {
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456");
|
||||
LoginRequest request = new LoginRequest("email@domain.com", "123456", "123456");
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
existing.setId(919L);
|
||||
@@ -444,4 +453,213 @@ class AuthServiceTest {
|
||||
|
||||
Assertions.assertThrows(UserNotFoundException.class, () -> authService.getCurrentUser());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Confirm user account happy path should succeed")
|
||||
void confirmUserAccount_happyPath_shouldSucceed() {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
|
||||
UserEntity user = new UserEntity();
|
||||
user.setEmailUuid(UUID.fromString(uuid));
|
||||
when(userRepository.findByEmailUuid(UUID.fromString(uuid))).thenReturn(Optional.of(user));
|
||||
when(userRepository.save(any())).thenReturn(user);
|
||||
|
||||
Assertions.assertDoesNotThrow(() -> authService.confirmUserAccount(uuid));
|
||||
verify(userRepository, times(1)).save(user);
|
||||
Assertions.assertNotNull(user.getEmailConfirmedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Confirm user account with invalid UUID should fail")
|
||||
void confirmUserAccount_invalidUuid_shouldFail() {
|
||||
String invalidUuid = "invalid-uuid";
|
||||
|
||||
Assertions.assertThrows(
|
||||
BadUuidException.class,
|
||||
() -> {
|
||||
authService.confirmUserAccount(invalidUuid);
|
||||
});
|
||||
verify(userRepository, times(0)).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Confirm user account with non-existent UUID should fail")
|
||||
void confirmUserAccount_nonExistentUuid_shouldFail() {
|
||||
String uuid = UUID.randomUUID().toString();
|
||||
|
||||
when(userRepository.findByEmailUuid(UUID.fromString(uuid))).thenReturn(Optional.empty());
|
||||
|
||||
Assertions.assertThrows(
|
||||
UserNotFoundException.class,
|
||||
() -> {
|
||||
authService.confirmUserAccount(uuid);
|
||||
});
|
||||
verify(userRepository, times(0)).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Resend email confirmation happy path should succeed")
|
||||
void resendEmailConfirmation_happyPath_shouldSucceed() {
|
||||
String email = "user@domain.com";
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
existing.setId(919L);
|
||||
existing.setEmail(email);
|
||||
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
|
||||
|
||||
doNothing().when(mailgunEmailService).sendNewUser(existing);
|
||||
|
||||
Assertions.assertDoesNotThrow(() -> authService.resendEmailConfirmation(email));
|
||||
verify(mailgunEmailService, times(1)).sendNewUser(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Resend email confirmation with non-existent email should fail")
|
||||
void resendEmailConfirmation_nonExistentEmail_shouldFail() {
|
||||
String email = "nonexistent@domain.com";
|
||||
|
||||
when(userRepository.findByEmail(email)).thenReturn(Optional.empty());
|
||||
|
||||
Assertions.assertThrows(
|
||||
UserNotFoundException.class, () -> authService.resendEmailConfirmation(email));
|
||||
verify(mailgunEmailService, times(0)).sendNewUser(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reset password for user happy path should succeed")
|
||||
void resetPasswordForUser_happyPath_shouldSucceed() {
|
||||
String email = "user@domain.com";
|
||||
|
||||
UserEntity existing = new UserEntity();
|
||||
existing.setId(919L);
|
||||
existing.setEmail(email);
|
||||
when(userRepository.findByEmail(email)).thenReturn(Optional.of(existing));
|
||||
|
||||
doNothing().when(mailgunEmailService).sendResetPassword(any());
|
||||
when(userRepository.save(any())).thenReturn(existing);
|
||||
|
||||
Assertions.assertDoesNotThrow(() -> authService.resetPasswordForUser(email));
|
||||
verify(userRepository, times(1)).save(existing);
|
||||
verify(mailgunEmailService, times(1)).sendResetPassword(existing);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Reset password for user with non-existent email should succeed without exception")
|
||||
void resetPasswordForUser_nonExistentEmail_shouldSucceed() {
|
||||
String email = "nonexistent@domain.com";
|
||||
|
||||
when(userRepository.findByEmail(email)).thenReturn(Optional.empty());
|
||||
|
||||
Assertions.assertDoesNotThrow(() -> authService.resetPasswordForUser(email));
|
||||
verify(userRepository, times(0)).save(any());
|
||||
verify(mailgunEmailService, times(0)).sendResetPassword(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Confirm reset password happy path should succeed")
|
||||
void confirmResetPasswordForUser_happyPath_shouldSucceed() {
|
||||
String token = "validToken";
|
||||
UserEntity user = new UserEntity();
|
||||
user.setResetToken(token);
|
||||
user.setResetPasswordExpiration(LocalDateTime.now().plusMinutes(30));
|
||||
|
||||
String newPassword = "NewPassword@123";
|
||||
|
||||
String encodedPassword = "hash@abcxasd123!";
|
||||
when(passwordEncoder.encode(newPassword)).thenReturn(encodedPassword);
|
||||
user.setPassword(encodedPassword);
|
||||
|
||||
when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user));
|
||||
when(authUtil.validatePassword(newPassword)).thenReturn(Optional.empty());
|
||||
|
||||
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword);
|
||||
|
||||
Assertions.assertDoesNotThrow(() -> authService.confirmResetPasswordForUser(request));
|
||||
|
||||
verify(userRepository, times(1)).save(user);
|
||||
verify(mailgunEmailService, times(1)).sendPasswordResetConfirmation(user);
|
||||
Assertions.assertNull(user.getResetToken());
|
||||
Assertions.assertNull(user.getResetPasswordExpiration());
|
||||
Assertions.assertNotNull(user.getPassword());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Confirm reset password with expired token should fail")
|
||||
void confirmResetPasswordForUser_expiredToken_shouldFail() {
|
||||
String token = "expiredToken";
|
||||
String newPassword = "NewPassword@123";
|
||||
|
||||
UserEntity user = new UserEntity();
|
||||
user.setResetToken(token);
|
||||
user.setResetPasswordExpiration(LocalDateTime.now().minusHours(3));
|
||||
|
||||
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword);
|
||||
|
||||
when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user));
|
||||
|
||||
Assertions.assertThrows(
|
||||
ResetExpiredException.class, () -> authService.confirmResetPasswordForUser(request));
|
||||
|
||||
verify(userRepository, times(0)).save(any());
|
||||
verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Confirm reset password with invalid token should fail")
|
||||
void confirmResetPasswordForUser_invalidToken_shouldFail() {
|
||||
String token = "invalidToken";
|
||||
String newPassword = "NewPassword@123";
|
||||
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword);
|
||||
|
||||
when(userRepository.findByResetToken(token)).thenReturn(Optional.empty());
|
||||
|
||||
Assertions.assertThrows(
|
||||
UserNotFoundException.class, () -> authService.confirmResetPasswordForUser(request));
|
||||
|
||||
verify(userRepository, times(0)).save(any());
|
||||
verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Confirm reset password with mismatched passwords should fail")
|
||||
void confirmResetPasswordForUser_mismatchedPasswords_shouldFail() {
|
||||
String token = "validToken";
|
||||
String newPassword = "NewPassword@123";
|
||||
String mismatchedPassword = "Mismatch@123";
|
||||
|
||||
UserEntity user = new UserEntity();
|
||||
user.setResetToken(token);
|
||||
user.setResetPasswordExpiration(LocalDateTime.now().plusMinutes(30));
|
||||
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, mismatchedPassword);
|
||||
|
||||
when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user));
|
||||
|
||||
Assertions.assertThrows(
|
||||
BadPasswordException.class, () -> authService.confirmResetPasswordForUser(request));
|
||||
|
||||
verify(userRepository, times(0)).save(any());
|
||||
verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Confirm reset password with invalid password should fail")
|
||||
void confirmResetPasswordForUser_invalidPassword_shouldFail() {
|
||||
String token = "validToken";
|
||||
String newPassword = "weak";
|
||||
|
||||
UserEntity user = new UserEntity();
|
||||
user.setResetToken(token);
|
||||
user.setResetPasswordExpiration(LocalDateTime.now().plusMinutes(30));
|
||||
|
||||
PasswordResetRequest request = new PasswordResetRequest(token, newPassword, newPassword);
|
||||
|
||||
when(userRepository.findByResetToken(token)).thenReturn(Optional.of(user));
|
||||
when(authUtil.validatePassword(newPassword)).thenReturn(Optional.of("Weak password"));
|
||||
|
||||
Assertions.assertThrows(
|
||||
BadPasswordException.class, () -> authService.confirmResetPasswordForUser(request));
|
||||
|
||||
verify(userRepository, times(0)).save(any());
|
||||
verify(mailgunEmailService, times(0)).sendPasswordResetConfirmation(any());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package br.com.tasknoteapp.server.service;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import br.com.tasknoteapp.server.entity.UserEntity;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/** Test class generated by Copilot. I had to tweak it, to make it work. */
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MailgunEmailServiceTest {
|
||||
|
||||
@Mock private RestTemplate restTemplate;
|
||||
@Mock private RestTemplateBuilder restTemplateBuilder;
|
||||
|
||||
private MailgunEmailService mailgunEmailService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
String apiKey = "abx123";
|
||||
String domain = "domain.com";
|
||||
String sender = "no-reply@domain.com";
|
||||
String target = "development";
|
||||
|
||||
when(restTemplateBuilder.defaultHeader(any(), any())).thenReturn(restTemplateBuilder);
|
||||
when(restTemplateBuilder.build()).thenReturn(restTemplate);
|
||||
|
||||
mailgunEmailService =
|
||||
new MailgunEmailService(apiKey, domain, sender, target, restTemplateBuilder);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSendResetPassword() {
|
||||
UserEntity user = new UserEntity();
|
||||
user.setEmail("test@example.com");
|
||||
user.setResetToken("reset-token");
|
||||
|
||||
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
|
||||
.thenReturn(ResponseEntity.ok("Success"));
|
||||
|
||||
mailgunEmailService.sendResetPassword(user);
|
||||
|
||||
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSendPasswordResetConfirmation() {
|
||||
UserEntity user = new UserEntity();
|
||||
user.setEmail("test@example.com");
|
||||
|
||||
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
|
||||
.thenReturn(ResponseEntity.ok("Success"));
|
||||
|
||||
mailgunEmailService.sendPasswordResetConfirmation(user);
|
||||
|
||||
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSendNewUser() {
|
||||
UserEntity user = new UserEntity();
|
||||
user.setEmail("test@example.com");
|
||||
user.setEmailUuid(java.util.UUID.randomUUID());
|
||||
|
||||
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
|
||||
.thenReturn(ResponseEntity.ok("Success"));
|
||||
|
||||
mailgunEmailService.sendNewUser(user);
|
||||
|
||||
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSendEmailHandlesHttpClientErrorException() {
|
||||
UserEntity user = new UserEntity();
|
||||
user.setEmail("test@example.com");
|
||||
user.setResetToken("reset-token");
|
||||
|
||||
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
|
||||
.thenThrow(new HttpClientErrorException(HttpStatusCode.valueOf(400)));
|
||||
|
||||
mailgunEmailService.sendResetPassword(user);
|
||||
|
||||
verify(restTemplate, times(1)).postForEntity(anyString(), any(), eq(String.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package br.com.tasknoteapp.server.templates;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class MailgunTemplateTest {
|
||||
|
||||
@Test
|
||||
void mailgunTemplateResetPwdTest() {
|
||||
MailgunTemplateResetPwd reset = new MailgunTemplateResetPwd();
|
||||
|
||||
Assertions.assertNotNull(reset.getName());
|
||||
Assertions.assertNotNull(reset.getVariables());
|
||||
Assertions.assertNotNull(reset.getVariableValuesJson());
|
||||
Assertions.assertFalse(reset.getVariableValuesJson().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mailgunTemplateResetPwdConfirmTest() {
|
||||
// MailgunTemplateResetPwdConfirm
|
||||
MailgunTemplateResetPwdConfirm confirm = new MailgunTemplateResetPwdConfirm();
|
||||
|
||||
Assertions.assertNotNull(confirm.getName());
|
||||
Assertions.assertNotNull(confirm.getVariables());
|
||||
Assertions.assertNotNull(confirm.getVariableValuesJson());
|
||||
Assertions.assertFalse(confirm.getVariableValuesJson().isBlank());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mailgunTemplateSignUpTest() {
|
||||
// MailgunTemplateSignUp
|
||||
|
||||
MailgunTemplateSignUp signUp = new MailgunTemplateSignUp();
|
||||
|
||||
Assertions.assertNotNull(signUp.getName());
|
||||
Assertions.assertNotNull(signUp.getVariables());
|
||||
Assertions.assertNotNull(signUp.getVariableValuesJson());
|
||||
Assertions.assertFalse(signUp.getVariableValuesJson().isBlank());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package br.com.tasknoteapp.server.util;
|
||||
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class TokenUtilTest {
|
||||
|
||||
@Test
|
||||
void generateTokenTest() {
|
||||
String token = new TokenUtil().generateToken();
|
||||
|
||||
Assertions.assertNotNull(token);
|
||||
Assertions.assertTrue(token.length() >= 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package br.com.tasknoteapp.server.util;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class UuidUtilTest {
|
||||
|
||||
@Test
|
||||
void generateEmailUuidTest() {
|
||||
UuidUtil uuidUtil = new UuidUtil();
|
||||
String email = "example@test.com";
|
||||
UUID uuid = uuidUtil.generateEmailUuid(email);
|
||||
|
||||
Assertions.assertNotNull(uuid);
|
||||
Assertions.assertEquals(uuid, uuidUtil.generateEmailUuid(email));
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,16 @@ br:
|
||||
tasknote:
|
||||
server:
|
||||
jwt-secret: ${SECURITY_KEY:test-secret-key-not-for-production}
|
||||
target-env: ${TARGET_ENV:development}
|
||||
version: ${BUILD:local}
|
||||
cors:
|
||||
allowed-origins: "${CORS_ALLOWED_ORIGINS:http://localhost}\t"
|
||||
|
||||
mailgun:
|
||||
api-key: ${MAILGUN_APIKEY:abc123456}
|
||||
domain: tasknoteapp.dev.br
|
||||
sender-email: no-reply@yourdomain.com
|
||||
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Create test user
|
||||
insert into users (email, password, admin, created_at, inactivated_at, email_uuid, reset_token)
|
||||
select 'testuuid@domain.com', 'a1b2c3d4f5g6', false, current_timestamp, null, 'cc2b5506-83ed-5764-985e-611ad4ce8050', 'abc123456'
|
||||
where not exists (select 1 from users where email = 'testuuid@domain.com');
|
||||
Reference in New Issue
Block a user