diff --git a/src/presentation/routing/account_settings.py b/src/presentation/routing/account_settings.py index f6d6280..a1be2fa 100644 --- a/src/presentation/routing/account_settings.py +++ b/src/presentation/routing/account_settings.py @@ -1,15 +1,23 @@ from fastapi import APIRouter, Depends, Request from fastapi.responses import ORJSONResponse from starlette import status -from src.application.commands import SetPhoneCommand, SetAvatarCommand, DeleteAvatarCommand +from src.application.commands import ( + SetPhoneCommand, + SetAvatarCommand, + DeleteAvatarCommand, + ChangePasswordStartCommand, + ChangePasswordCompleteCommand, +) from src.application.domain.dto import AuthContext from src.presentation.decorators import require_access_token, csrf_protect from src.presentation.dependencies import ( get_delete_avatar_command, get_set_avatar_command, get_set_phone_command, + get_change_password_start_command, + get_change_password_complete_command, ) -from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest +from src.presentation.schemas import SetAvatarRequest, SetPhoneRequest, ChangePasswordConfirmRequest from src.presentation.schemas.api_errors import ApiErrorPayload, ApiValidationErrorsPayload from src.presentation.schemas.me_public import MeUserPublicResponse, SetAvatarPublicResponse from src.presentation.serializers import me_user_public @@ -46,6 +54,38 @@ _SET_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = { } +_PASSWORD_ERROR_RESPONSES: dict[int, dict[str, object]] = { + status.HTTP_400_BAD_REQUEST: { + 'description': 'Неверный или просроченный код, пароли не совпадают или совпадают с текущим.', + 'model': ApiErrorPayload, + }, + status.HTTP_401_UNAUTHORIZED: { + 'description': 'Не передан или неверен access token.', + 'model': ApiErrorPayload, + }, + status.HTTP_404_NOT_FOUND: { + 'description': 'Учётная запись не найдена или у пользователя нет email.', + 'model': ApiErrorPayload, + }, + status.HTTP_422_UNPROCESSABLE_ENTITY: { + 'description': 'Тело запроса не соответствует схеме (код, длина пароля).', + 'model': ApiValidationErrorsPayload, + }, + status.HTTP_429_TOO_MANY_REQUESTS: { + 'description': 'Код уже отправлен или слишком частые запросы.', + 'model': ApiErrorPayload, + }, + status.HTTP_500_INTERNAL_SERVER_ERROR: { + 'description': 'Внутренняя ошибка сервера.', + 'model': ApiErrorPayload, + }, + status.HTTP_503_SERVICE_UNAVAILABLE: { + 'description': 'Временная ошибка отправки кода или сохранения в кеш.', + 'model': ApiErrorPayload, + }, +} + + _DELETE_AVATAR_ERROR_RESPONSES: dict[int, dict[str, object]] = { status.HTTP_401_UNAUTHORIZED: { 'description': 'Не передан или неверен access token.', @@ -125,6 +165,52 @@ async def delete_avatar( user = await command(user_id=auth.user_id) return me_user_public(user) + +@account_settings_router.post( + path='/password/start', + response_class=ORJSONResponse, + status_code=status.HTTP_200_OK, + summary='Запросить код для смены пароля', + description='Отправляет шестизначный код на email текущего пользователя. Повторный запрос возможен после истечения TTL.', + responses=_PASSWORD_ERROR_RESPONSES, +) +@csrf_protect() +async def change_password_start( + request: Request, + auth: AuthContext = Depends(require_access_token), + command: ChangePasswordStartCommand = Depends(get_change_password_start_command), +): + result = await command(user_id=auth.user_id) + return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) + + +@account_settings_router.post( + path='/password/complete', + response_class=ORJSONResponse, + status_code=status.HTTP_200_OK, + summary='Подтвердить смену пароля', + description=( + 'Принимает код из письма, новый пароль и его подтверждение. ' + 'Новый пароль должен отличаться от текущего (минимум 8 символов).' + ), + responses=_PASSWORD_ERROR_RESPONSES, +) +@csrf_protect() +async def change_password_complete( + request: Request, + body: ChangePasswordConfirmRequest, + auth: AuthContext = Depends(require_access_token), + command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command), +): + result = await command( + user_id=auth.user_id, + code=body.code, + new_password=body.new_password, + confirm_password=body.confirm_password, + ) + return ORJSONResponse(status_code=status.HTTP_200_OK, content={'success': result}) + + # # @account_settings_router.post(path='/encrypted-mnemonic/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) # async def encrypted_mnemonic_start( @@ -183,32 +269,6 @@ async def delete_avatar( # return {'success': result} # # -# @account_settings_router.post(path='/password/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -# async def change_password_start( -# request: Request, -# auth: AuthContext = Depends(require_access_token), -# command: ChangePasswordStartCommand = Depends(get_change_password_start_command), -# ): -# result = await command(user_id=auth.user_id) -# return {'success': result} -# -# -# @account_settings_router.post(path='/password/complete', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) -# async def change_password_complete( -# request: Request, -# body: ChangePasswordConfirmRequest, -# auth: AuthContext = Depends(require_access_token), -# command: ChangePasswordCompleteCommand = Depends(get_change_password_complete_command), -# ): -# result = await command( -# user_id=auth.user_id, -# code=body.code, -# new_password=body.new_password, -# confirm_password=body.confirm_password, -# ) -# return {'success': result} -# -# # @account_settings_router.post(path='/bank/start', response_class=ORJSONResponse, status_code=status.HTTP_200_OK) # async def bank_details_start( # request: Request,