diff --git a/README.md b/README.md index ca77612..cdef7b7 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Backlog — сервис «списка хотелок» (backlog) для фил - Alembic (миграции) - PostgreSQL - taskiq (фоновые задачи) +- RabbitMQ - Jinja2 ### Frontend @@ -139,6 +140,12 @@ curl -X POST "http://localhost:8000/api/movies" \ - Защищённые эндпоинты требуют заголовок: `Authorization: Bearer ` + +## Брокер сообщений +- Запуск: +```bash +taskiq worker backlog_app.taskiq_broker:broker --fs-discover -tp "**/tasks" --no-configure-logging +``` ## Тестирование - Запуск тестов: diff --git a/backend/backlog_app/api/view/auth_view.py b/backend/backlog_app/api/view/auth_view.py index 3ff52a9..b3f6889 100644 --- a/backend/backlog_app/api/view/auth_view.py +++ b/backend/backlog_app/api/view/auth_view.py @@ -14,7 +14,7 @@ router.include_router( fastapi_users.get_auth_router( authentication_backend, - # requires_verification=True, # It is not necessary to include the parameter in this application. + requires_verification=True, # It is not necessary to include the parameter in this application. ) ) diff --git a/backend/backlog_app/config.py b/backend/backlog_app/config.py index e88b236..1cbf46a 100644 --- a/backend/backlog_app/config.py +++ b/backend/backlog_app/config.py @@ -2,7 +2,7 @@ from pathlib import Path from typing import Literal -from pydantic import BaseModel, field_validator +from pydantic import AmqpDsn, BaseModel, field_validator from pydantic_settings import ( BaseSettings, PydanticBaseSettingsSource, @@ -17,6 +17,9 @@ class LoggingConfig(BaseModel): log_format: str = ( "[-] %(asctime)s [%(levelname)s] %(module)s-%(lineno)d - %(message)s" ) + worker_log_format: str = ( + "[-] %(asctime)s [%(levelname)s] [%(processName)s] %(module)s-%(lineno)d - %(message)s" + ) log_level_name: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "WARNING" log_date_format: str = "%Y-%m-%d %H:%M:%S" @@ -52,6 +55,10 @@ class DataBase(BaseModel): connection: DataBaseConnection +class TaskiqConfig(BaseModel): + url: AmqpDsn = "amqp://guest:guest@localhost:5672//" + + class Settings(BaseSettings): model_config = SettingsConfigDict( case_sensitive=False, @@ -99,6 +106,7 @@ def settings_customise_sources( ) db: DataBase + taskiq: TaskiqConfig = TaskiqConfig() logging: LoggingConfig = LoggingConfig() access_token_db: AccessToken superuser: SuperUser diff --git a/backend/backlog_app/servicies/authentification/user_manager.py b/backend/backlog_app/servicies/authentification/user_manager.py index 289e584..d0a9768 100644 --- a/backend/backlog_app/servicies/authentification/user_manager.py +++ b/backend/backlog_app/servicies/authentification/user_manager.py @@ -6,10 +6,11 @@ from backlog_app.config import settings from backlog_app.models import User -from backlog_app.tasks.email_task import send_email_confirmed, send_verification_email +from backlog_app.servicies.mailing import format_seconds_for_email +from backlog_app.tasks import email_task if TYPE_CHECKING: - from fastapi import Request + from fastapi import Request, Response logger = logging.getLogger(__name__) @@ -19,36 +20,67 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): verification_token_secret = settings.access_token_db.verification_token_secret async def on_after_register(self, user: User, request: Optional["Request"] = None): - logger.warning("User %s has registered.", user.id) + logger.info("User <%s> has registered.", user.id) - async def on_after_forgot_password( + async def on_after_request_verify( self, user: User, token: str, request: Optional["Request"] = None ): - logger.warning( - "User %s has forgot their password. Reset token: $s", user.id, token + logger.debug( + "Verification requested for user <%s>. Verification token: %s", + user.id, + token, ) - async def on_after_request_verify( + origin = request.headers.get("origin") or settings.FRONTEND_URL + logger.debug("origin url: %s", origin) + + verification_link = f"{origin}/verify?token={token}" + await email_task.send_verification_email.kiq( + user_email=user.email, + verification_link=verification_link, + ) + + async def on_after_verify(self, user: User, request: Optional["Request"] = None): + logger.warning("User <%s> has been verified", user.id) + + await email_task.send_email_confirmed.kiq( + user_email=user.email, + ) + + async def on_after_login( + self, + user: User, + request: Optional["Request"] = None, + response: Optional["Response"] | None = None, + ) -> None: + logger.warning("User <%s> has logged in", user.id) + + async def on_after_forgot_password( self, user: User, token: str, request: Optional["Request"] = None ): - logger.warning( - "Verification requested for user %s. Verification token: %s", user.id, token + logger.debug( + "User <%s> has forgot their password. Reset token: %s, lifetime: %s", + user.id, + token, + self.reset_password_token_lifetime_seconds, ) - - verification_link = ( - "http://127.0.0.1:8000/docs#/Auth/verify_verify_api_auth_verify_post" + origin = request.headers.get("origin") or settings.FRONTEND_URL + reset_link = f"{origin}/reset-password?token={token}" + token_lifetime = format_seconds_for_email( + self.reset_password_token_lifetime_seconds ) - await send_verification_email.kiq( - user_id=str(user.id), + + await email_task.send_email_forgot_password.kiq( user_email=user.email, - verification_token=token, - verification_link=verification_link, + reset_link=reset_link, + token_lifetime=token_lifetime, ) - async def on_after_verify(self, user: User, request: Optional["Request"] = None): - logger.warning("User %s has been verified", user.id) + async def on_after_reset_password( + self, user: User, request: Optional["Request"] = None + ) -> None: + logger.warning("User <%s> successfully changed their password", user.id) - await send_email_confirmed.kiq( - user_id=str(user.id), + await email_task.send_email_forgot_password_confirmed.kiq( user_email=user.email, ) diff --git a/backend/backlog_app/servicies/mailing/__init__.py b/backend/backlog_app/servicies/mailing/__init__.py index e69de29..405374e 100644 --- a/backend/backlog_app/servicies/mailing/__init__.py +++ b/backend/backlog_app/servicies/mailing/__init__.py @@ -0,0 +1,2 @@ +from .email_formater import format_seconds_for_email +from .email_sender import send_email diff --git a/backend/backlog_app/servicies/mailing/email_formater.py b/backend/backlog_app/servicies/mailing/email_formater.py new file mode 100644 index 0000000..2e945b6 --- /dev/null +++ b/backend/backlog_app/servicies/mailing/email_formater.py @@ -0,0 +1,18 @@ +def format_seconds_for_email(seconds: int) -> str: + """ + Converts a number of seconds into a human-readable string for emails. + + Examples: + 3600 -> "1 hour" + 5400 -> "1 hour 30 min" + 2700 -> "45 min" + """ + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + + if hours > 0 and minutes == 0: + return f"{hours} hour{'s' if hours > 1 else ''}" + elif hours > 0 and minutes > 0: + return f"{hours} hour{'s' if hours > 1 else ''} {minutes} min" + else: + return f"{minutes} min" diff --git a/backend/backlog_app/servicies/mailing/send_email.py b/backend/backlog_app/servicies/mailing/email_sender.py similarity index 100% rename from backend/backlog_app/servicies/mailing/send_email.py rename to backend/backlog_app/servicies/mailing/email_sender.py diff --git a/backend/backlog_app/taskiq_broker.py b/backend/backlog_app/taskiq_broker.py index 24f040e..8793cb9 100644 --- a/backend/backlog_app/taskiq_broker.py +++ b/backend/backlog_app/taskiq_broker.py @@ -1,3 +1,24 @@ -from taskiq import InMemoryBroker +import logging -broker = InMemoryBroker() +import taskiq_fastapi +from taskiq import TaskiqEvents, TaskiqState +from taskiq_aio_pika import AioPikaBroker + +from backlog_app.config import settings + +logger = logging.getLogger(__name__) + +broker = AioPikaBroker(url=settings.taskiq.url) + +taskiq_fastapi.init(broker, "backlog_app.main:app") + + +@broker.on_event(TaskiqEvents.WORKER_STARTUP) +async def on_worker_startup(state: TaskiqState) -> None: + logging.basicConfig( + format=settings.logging.worker_log_format, + level=settings.logging.log_level, + datefmt=settings.logging.log_date_format, + ) + + logger.info("Worker startup complete, got state: %s", state) diff --git a/backend/backlog_app/tasks/__init__.py b/backend/backlog_app/tasks/__init__.py index e69de29..33fac74 100644 --- a/backend/backlog_app/tasks/__init__.py +++ b/backend/backlog_app/tasks/__init__.py @@ -0,0 +1,6 @@ +from .email_task import ( + send_email_confirmed, + send_email_forgot_password, + send_email_forgot_password_confirmed, + send_verification_email, +) diff --git a/backend/backlog_app/tasks/email_task.py b/backend/backlog_app/tasks/email_task.py index e38b8b9..6a46bfb 100644 --- a/backend/backlog_app/tasks/email_task.py +++ b/backend/backlog_app/tasks/email_task.py @@ -1,35 +1,30 @@ from textwrap import dedent from backlog_app.jinja2_templates import templates -from backlog_app.servicies.mailing import send_email +from backlog_app.servicies.mailing.email_sender import send_email from backlog_app.taskiq_broker import broker @broker.task async def send_verification_email( - user_id: str, user_email: str, - verification_token: str, verification_link: str, -): - subject = "Confirm your email for site.com" +) -> None: + subject = "Confirm your email" plain_content = dedent(f"""\ Dear {user_email}, Please verify your email for site.com at {verification_link}. - Use this token to verify your email: {verification_token} - Your site admin, 2025 """) template = templates.get_template("email-verify/verification-request.html") context = { - "user_id": user_id, + "user_email": user_email, "verification_link": verification_link, - "verification_token": verification_token, } - html_content = template.render(context=context) + html_content = template.render(context) await send_email( recipient=user_email, @@ -41,7 +36,6 @@ async def send_verification_email( @broker.task async def send_email_confirmed( - user_id: str, user_email: str, ): subject = "Email Confirmed" @@ -53,10 +47,60 @@ async def send_email_confirmed( 2025""") template = templates.get_template("email-verify/email-verified.html") context = { - "user_id": user_id, "user_email": user_email, } - html_content = template.render(context=context) + html_content = template.render(context) + + await send_email( + recipient=user_email, + subject=subject, + plain_content=plain_content, + html_content=html_content, + ) + + +@broker.task +async def send_email_forgot_password( + user_email: str, reset_link: str, token_lifetime: str +): + subject = "Request for change password" + plain_content = dedent(f"""\ + Dear {user_email}, + We get your request for change password. + Link for change: {reset_link} + Your site admin, + 2025""") + template = templates.get_template("email-forgot/password-reset-request.html") + context = { + "user_email": user_email, + "reset_link": reset_link, + "expires_in": token_lifetime, + } + html_content = template.render(context) + + await send_email( + recipient=user_email, + subject=subject, + plain_content=plain_content, + html_content=html_content, + ) + + +@broker.task +async def send_email_forgot_password_confirmed( + user_email: str, +): + subject = "Password reset request confirmed" + plain_content = dedent(f"""\ + Dear {user_email}, + Your password reset request has been confirmed. + Your site admin, + 2025""") + template = templates.get_template("email-forgot/password-reset-confirmed.html") + context = { + "user_email": user_email, + } + html_content = template.render(context) await send_email( recipient=user_email, diff --git a/backend/backlog_app/templates/base.html b/backend/backlog_app/templates/base.html index f6e9754..14b7b00 100644 --- a/backend/backlog_app/templates/base.html +++ b/backend/backlog_app/templates/base.html @@ -10,13 +10,5 @@ {% block main %} {% endblock %} - - diff --git a/backend/backlog_app/templates/email-forgot/password-reset-confirmed.html b/backend/backlog_app/templates/email-forgot/password-reset-confirmed.html new file mode 100644 index 0000000..a54c383 --- /dev/null +++ b/backend/backlog_app/templates/email-forgot/password-reset-confirmed.html @@ -0,0 +1,71 @@ +{% extends 'base.html' %} + +{% block title %} + Password successfully changed +{% endblock %} + +{% block main %} + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ 🔑 Password changed successfully +

+
+ Hello {{ user_email }}, +
+ Your password has been successfully updated. You can now use your new password to log in to your account. +
+ + Log in to your account + +
+ If you did not perform this action, please contact our support immediately. +
+ This is an automated message. Please do not reply to this email. +
+
+{% endblock %} diff --git a/backend/backlog_app/templates/email-forgot/password-reset-request.html b/backend/backlog_app/templates/email-forgot/password-reset-request.html new file mode 100644 index 0000000..553f684 --- /dev/null +++ b/backend/backlog_app/templates/email-forgot/password-reset-request.html @@ -0,0 +1,84 @@ +{% extends 'base.html' %} + +{% block title %} + Password reset request +{% endblock %} + +{% block main %} + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ 🔐 Password reset request +

+
+ Hello {{ user_email }}, +
+ We received a request to reset your account password. + Click the button below to set a new password. +
+ + Reset Password + +
+ If the button doesn’t work, copy and paste this link into your browser: +
+ + {{ reset_link }} + +
+ If you did not request a password reset, please ignore this email. + Your password will remain unchanged. +
+ This link will expire in {{ expires_in | default("24 hours") }}. +
+
+{% endblock %} diff --git a/backend/backlog_app/templates/email-verify/email-verified.html b/backend/backlog_app/templates/email-verify/email-verified.html index 4d4ecfa..df43ffd 100644 --- a/backend/backlog_app/templates/email-verify/email-verified.html +++ b/backend/backlog_app/templates/email-verify/email-verified.html @@ -5,10 +5,58 @@ {% endblock %} {% block main %} -

- Dear, {{ user_email }}, -

-

- Email confirmed. Your email address has been verified. -

+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ ✅ Email successfully confirmed +

+
+ Hello {{ user_email }}, +
+ Your email address has been successfully verified. + Your account is now fully activated and ready to use. +
+ + Go to Login + +
+ If you did not perform this action, please contact support immediately. +
+
{% endblock %} diff --git a/backend/backlog_app/templates/email-verify/verification-request.html b/backend/backlog_app/templates/email-verify/verification-request.html index de56342..8f33409 100644 --- a/backend/backlog_app/templates/email-verify/verification-request.html +++ b/backend/backlog_app/templates/email-verify/verification-request.html @@ -1,16 +1,73 @@ {% extends 'base.html' %} {% block title %} - Email verification request + Email verification {% endblock %} {% block main %} -

- Dear, {{ user_email }}, -

-

- To verify your email address, please follow the link: -
- Confirm email -

+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

+ Confirm your email +

+
+ Hello {{ user_email }}, +
+ Thanks for registering. Please confirm your email address by clicking the button below. +
+ + Verify Email + +
+ If the button doesn’t work, copy and paste this link into your browser: +
+ + {{ verification_link }} + +
+ This link will expire in 24 hours.
+ If you didn’t request this, you can safely ignore this email. +
+
{% endblock %} diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 53a4495..5234588 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,8 @@ dependencies = [ "pydantic-settings[yaml]>=2.12.0", "sqlalchemy[asyncio]>=2.0.46", "taskiq>=0.12.1", + "taskiq-aio-pika>=0.5.0", + "taskiq-fastapi>=0.4.0", "taskiq-redis>=1.2.1", "uvicorn>=0.40.0", ] diff --git a/backend/uv.lock b/backend/uv.lock index 46e547e..a023510 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -6,6 +6,19 @@ resolution-markers = [ "python_full_version < '3.14'", ] +[[package]] +name = "aio-pika" +version = "9.5.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiormq" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/73/8d1020683970de5532b3b01732d75c8bf922a6505fcdad1a9c7c6405242a/aio_pika-9.5.8.tar.gz", hash = "sha256:7c36874115f522bbe7486c46d8dd711a4dbedd67c4e8a8c47efe593d01862c62", size = 47408, upload-time = "2025-11-12T10:37:10.215Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/91/513971861d845d28160ecb205ae2cfaf618b16918a9cd4e0b832b5360ce7/aio_pika-9.5.8-py3-none-any.whl", hash = "sha256:f4c6cb8a6c5176d00f39fd7431e9702e638449bc6e86d1769ad7548b2a506a8d", size = 54397, upload-time = "2025-11-12T10:37:08.374Z" }, +] + [[package]] name = "aiohappyeyeballs" version = "2.6.1" @@ -83,6 +96,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, ] +[[package]] +name = "aiormq" +version = "6.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pamqp" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/f6/01bc850db6d9b46ae825e3c373f610b0544e725a1159745a6de99ad0d9f1/aiormq-6.9.2.tar.gz", hash = "sha256:d051d46086079934d3a7157f4d8dcb856b77683c2a94aee9faa165efa6a785d3", size = 30554, upload-time = "2025-10-20T10:49:59.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/ec/763b13f148f3760c1562cedb593feaffbae177eeece61af5d0ace7b72a3e/aiormq-6.9.2-py3-none-any.whl", hash = "sha256:ab0f4e88e70f874b0ea344b3c41634d2484b5dc8b17cb6ae0ae7892a172ad003", size = 31829, upload-time = "2025-10-20T10:49:58.547Z" }, +] + [[package]] name = "aiosignal" version = "1.4.0" @@ -255,6 +281,8 @@ dependencies = [ { name = "pydantic-settings", extra = ["yaml"] }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "taskiq" }, + { name = "taskiq-aio-pika" }, + { name = "taskiq-fastapi" }, { name = "taskiq-redis" }, { name = "uvicorn" }, ] @@ -282,6 +310,8 @@ requires-dist = [ { name = "pydantic-settings", extras = ["yaml"], specifier = ">=2.12.0" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.46" }, { name = "taskiq", specifier = ">=0.12.1" }, + { name = "taskiq-aio-pika", specifier = ">=0.5.0" }, + { name = "taskiq-fastapi", specifier = ">=0.4.0" }, { name = "taskiq-redis", specifier = ">=1.2.1" }, { name = "uvicorn", specifier = ">=0.40.0" }, ] @@ -1208,6 +1238,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pamqp" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993, upload-time = "2024-01-12T20:37:25.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848, upload-time = "2024-01-12T20:37:21.359Z" }, +] + [[package]] name = "pathspec" version = "1.0.4" @@ -1784,6 +1823,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4c/e4/a2fda3bcbb8b61108dc8e9db1a2d19a23578953db73e981f66b9d44f1207/taskiq-0.12.1-py3-none-any.whl", hash = "sha256:a8ade45e2e23edbadb972a88dec44e68c7daef83383d01fa3af48594a24a712a", size = 90668, upload-time = "2025-12-07T16:07:42.296Z" }, ] +[[package]] +name = "taskiq-aio-pika" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aio-pika" }, + { name = "taskiq" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/91/3c7b7af402df3299f74b6ab5a3e8b6ffbd45b1a9eaab9dce5e4e7b886709/taskiq_aio_pika-0.5.0.tar.gz", hash = "sha256:ded32dc4b9ad97ee6da7b297f393d33aad8f45bb444355ad342f1533e3d161d6", size = 7425, upload-time = "2025-11-25T08:59:14.184Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/0e/abfdc636b9f0624c2c78bb6e7f33d1673fb01c934c22f76cf91ea82677da/taskiq_aio_pika-0.5.0-py3-none-any.whl", hash = "sha256:bac8c1beaccbdc7af30eebd9c04b17a17e4fd4f30727bc99e4ca464aa2980177", size = 7799, upload-time = "2025-11-25T08:59:13.032Z" }, +] + [[package]] name = "taskiq-dependencies" version = "1.5.7" @@ -1793,6 +1845,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/6d/4a012f2de002c2e93273f5e7d3e3feea02f7fdbb7b75ca2ca1dd10703091/taskiq_dependencies-1.5.7-py3-none-any.whl", hash = "sha256:6fcee5d159bdb035ef915d4d848826169b6f06fe57cc2297a39b62ea3e76036f", size = 13801, upload-time = "2025-02-26T22:07:38.622Z" }, ] +[[package]] +name = "taskiq-fastapi" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastapi" }, + { name = "taskiq" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/fe/f9e885a4dd6bd4d4224e3dee05d9b38bf58102a0ef5e553e812d654dfa72/taskiq_fastapi-0.4.0.tar.gz", hash = "sha256:f683ab0aab1ab2db117d1e121e5e534f3ba4a4e5f382177012e29eca986863fb", size = 5819, upload-time = "2025-11-29T15:22:59.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/58/11eacc48d615703618f93ff44812409dca94cb465ab5cb173a8a90e079b1/taskiq_fastapi-0.4.0-py3-none-any.whl", hash = "sha256:9e7e57fa337b4fad2641c8761cafc7b54a63bf7bf726423a28fc2b15c55fd0bc", size = 5369, upload-time = "2025-11-29T15:23:00.185Z" }, +] + [[package]] name = "taskiq-redis" version = "1.2.1" diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index c072ea7..192bc58 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -20,5 +20,35 @@ services: retries: 5 restart: unless-stopped + maildev: + image: maildev/maildev + environment: + - TZ=Europe/Moscow + - MAILDEV_WEB_PORT=1080 + - MAILDEV_SMTP_PORT=1025 + ports: + - "8080:1080" + - "1025:1025" + logging: + driver: "json-file" + options: + max-size: "1m" + + rabbitmq: + image: rabbitmq:4.2-management-alpine + hostname: rabbitmq + container_name: rabbitmq + ports: + - "5672:5672" + - "15672:15672" + env_file: + - .env + environment: + RABBITMQ_DEFAULT_USER: ${RABBIT_USER} + RABBITMQ_DEFAULT_PASS: ${RABBIT_PSWD} + volumes: + - rabbitmq-data:/var/lib/rabbitmq + volumes: pgdata: + rabbitmq-data: diff --git a/frontend/src/components/CheckEmail.vue b/frontend/src/components/CheckEmail.vue new file mode 100644 index 0000000..5ed7a6a --- /dev/null +++ b/frontend/src/components/CheckEmail.vue @@ -0,0 +1,62 @@ + + + diff --git a/frontend/src/components/ForgotPassword.vue b/frontend/src/components/ForgotPassword.vue new file mode 100644 index 0000000..32b8ce9 --- /dev/null +++ b/frontend/src/components/ForgotPassword.vue @@ -0,0 +1,99 @@ + + + diff --git a/frontend/src/components/Login.vue b/frontend/src/components/Login.vue index 2a4389f..dd2d29f 100644 --- a/frontend/src/components/Login.vue +++ b/frontend/src/components/Login.vue @@ -18,14 +18,14 @@ Email @@ -35,17 +35,27 @@ Password + +
+ + Forgot password? + +
+

{{ error }}

@@ -53,9 +63,9 @@ diff --git a/frontend/src/components/Register.vue b/frontend/src/components/Register.vue index baccaa8..d868a8d 100644 --- a/frontend/src/components/Register.vue +++ b/frontend/src/components/Register.vue @@ -18,15 +18,15 @@ Email

{{ errors.email }}

@@ -37,15 +37,15 @@ Password

{{ errors.password }}

Minimum 8 characters

@@ -57,15 +57,15 @@ Confirm Password

{{ errors.confirmPassword }} @@ -86,9 +86,9 @@ @@ -171,14 +171,16 @@ export default { await authService.register(this.email, this.password) this.success = true - // Redirect to login after 2 seconds - setTimeout(() => { - this.$router.push('/login') - }, 2000) + this.$router.push({ + path: '/check-email', + query: { email: this.email } + }) + } catch (error) { console.error('Registration error:', error) this.error = - error.response?.data?.detail || 'Registration failed. Please try again.' + error.response?.data?.detail || + 'Registration failed. Please try again.' } finally { this.loading = false } diff --git a/frontend/src/components/ResetPassword.vue b/frontend/src/components/ResetPassword.vue new file mode 100644 index 0000000..8d949aa --- /dev/null +++ b/frontend/src/components/ResetPassword.vue @@ -0,0 +1,132 @@ + + + diff --git a/frontend/src/components/VerifyEmail.vue b/frontend/src/components/VerifyEmail.vue new file mode 100644 index 0000000..e78c7d8 --- /dev/null +++ b/frontend/src/components/VerifyEmail.vue @@ -0,0 +1,134 @@ + + + diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 9871919..52182eb 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -1,51 +1,76 @@ -import { createRouter, createWebHistory } from 'vue-router' +import {createRouter, createWebHistory} from 'vue-router' import authService from '../services/auth' import HomeView from '../components/Home.vue' import LoginView from '../components/Login.vue' import RegisterView from '../components/Register.vue' +import ForgotPasswordView from '../components/ForgotPassword.vue' +import ResetPasswordView from '../components/ResetPassword.vue' +import VerifyEmail from "../components/VerifyEmail.vue" +import CheckEmail from "../components/CheckEmail.vue"; const routes = [ - { - path: '/', - name: 'Home', - component: HomeView, - meta: { requiresAuth: true }, - }, - { - path: '/login', - name: 'Login', - component: LoginView, - meta: { guest: true }, - }, - { - path: '/register', - name: 'Register', - component: RegisterView, - meta: { guest: true }, - }, + { + path: '/', + name: 'Home', + component: HomeView, + meta: {requiresAuth: true}, + }, + { + path: '/login', + name: 'Login', + component: LoginView, + meta: {guest: true}, + }, + { + path: '/register', + name: 'Register', + component: RegisterView, + meta: {guest: true}, + }, + { + path: '/forgot-password', + name: 'ForgotPassword', + component: ForgotPasswordView, + meta: {guest: true}, + }, + { + path: '/reset-password', + name: 'ResetPassword', + component: ResetPasswordView, + meta: {guest: true}, + }, + { + path: '/check-email', + name: 'CheckEmail', + component: CheckEmail + }, + { + path: '/verify', + name: 'VerifyEmail', + component: VerifyEmail + } ] const router = createRouter({ - history: createWebHistory(), - routes, + history: createWebHistory(), + routes, }) -// Navigation guard router.beforeEach((to, from, next) => { - const isAuthenticated = authService.isAuthenticated() + const isAuthenticated = authService.isAuthenticated() - if (to.meta.requiresAuth && !isAuthenticated) { - next('/login') - return - } + if (to.meta.requiresAuth && !isAuthenticated) { + next('/login') + return + } - if (to.meta.guest && isAuthenticated) { - next('/') - return - } + if (to.meta.guest && isAuthenticated) { + next('/') + return + } - next() + next() }) export default router diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index 5262059..187c5b3 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -1,106 +1,144 @@ import axios from 'axios' class AuthService { - constructor() { - this.token = localStorage.getItem('token') - this.user = JSON.parse(localStorage.getItem('user') || 'null') - } - - async register(email, password) { - const response = await axios.post(`api/auth/register`, { - email, - password, - }) - return response.data - } + constructor() { + this.token = localStorage.getItem('token') + this.user = JSON.parse(localStorage.getItem('user') || 'null') + } - async login(email, password) { - const formData = new URLSearchParams() - formData.append('username', email) - formData.append('password', password) + async register(email, password) { + const response = await axios.post(`/api/auth/register`, { + email, + password, + }) - const response = await axios.post(`api/auth/login`, formData, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, + await axios.post(`/api/auth/request-verify-token`, { + email + }) + + return response.data + } + + async requestVerifyToken(email) { + const response = await axios.post('/api/auth/request-verify-token', { + email, }) - const { access_token } = response.data - this.token = access_token - localStorage.setItem('token', access_token) + return response.data +} - await this.fetchUser() + async verifyEmail(token) { + const response = await axios.post('/api/auth/verify', { + token, + }) return response.data - } - - async fetchUser() { - if (!this.token) return null - - try { - const response = await axios.get(`api/users/me`, { - headers: { - Authorization: `Bearer ${this.token}`, - }, - }) - this.user = response.data - localStorage.setItem('user', JSON.stringify(response.data)) - return response.data - } catch (error) { - this.logout() - throw error +} + + async login(email, password) { + const formData = new URLSearchParams() + formData.append('username', email) + formData.append('password', password) + + const response = await axios.post(`api/auth/login`, formData, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }) + + const {access_token} = response.data + this.token = access_token + localStorage.setItem('token', access_token) + + await this.fetchUser() + + return response.data } - } - - async logout() { - try { - if (this.token) { - await axios.post(`api/auth/logout`, null, { - headers: { - Authorization: `Bearer ${this.token}`, - }, + + async forgotPassword(email) { + const response = await axios.post(`api/auth/forgot-password`, { + email, }) - } - } catch (error) { - console.error('Logout error:', error) - } finally { - this.token = null - this.user = null - localStorage.removeItem('token') - localStorage.removeItem('user') + + return response.data } - } - isAuthenticated() { - return !!this.token - } + async resetPassword(token, password) { + const response = await axios.post(`api/auth/reset-password`, { + token, + password, + }) + + return response.data + } - getUser() { - return this.user - } + async fetchUser() { + if (!this.token) return null - setupAxiosInterceptor() { - axios.interceptors.request.use( - config => { - if (this.token) { - config.headers.Authorization = `Bearer ${this.token}` + try { + const response = await axios.get(`api/users/me`, { + headers: { + Authorization: `Bearer ${this.token}`, + }, + }) + this.user = response.data + localStorage.setItem('user', JSON.stringify(response.data)) + return response.data + } catch (error) { + this.logout() + throw error } - return config - }, - error => Promise.reject(error) - ) - - axios.interceptors.response.use( - response => response, - error => { - if (error.response?.status === 401) { - this.logout() - window.location.href = '/login' + } + + async logout() { + try { + if (this.token) { + await axios.post(`api/auth/logout`, null, { + headers: { + Authorization: `Bearer ${this.token}`, + }, + }) + } + } catch (error) { + console.error('Logout error:', error) + } finally { + this.token = null + this.user = null + localStorage.removeItem('token') + localStorage.removeItem('user') } - return Promise.reject(error) - } - ) - } + } + + isAuthenticated() { + return !!this.token + } + + getUser() { + return this.user + } + + setupAxiosInterceptor() { + axios.interceptors.request.use( + config => { + if (this.token) { + config.headers.Authorization = `Bearer ${this.token}` + } + return config + }, + error => Promise.reject(error) + ) + + axios.interceptors.response.use( + response => response, + error => { + if (error.response?.status === 401) { + this.logout() + window.location.href = '/login' + } + return Promise.reject(error) + } + ) + } } export default new AuthService()