From 3e790af6e56fa097572498e3ced3d5ebbd6c631d Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 12 Sep 2026 14:37:25 +0300 Subject: [PATCH] =?UTF-8?q?:envelope:=20feat(identity):=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=B3=D0=BB=D0=B0=D1=88=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D0=BE=D1=80=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20=E2=80=94=20=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D1=83=D1=8E=D1=89=D0=B8=D0=B5=20=D1=83=D1=87=D1=91=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BB=D0=B0=D0=B4=D0=B5=D0=BB=D0=B5=D1=86=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B8=D0=B6=D0=B8=D0=BD=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Учётная запись глобальная, а во вторую организацию было не попасть: форма создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой организации получает приглашение с той же ролью, должностью, телефоном и группами; членство появляется, когда человек принял его под своим входом. Письмо отправляет воркер и выпускает токен в момент отправки — открытый токен нигде не хранится. Повторное приглашение заменяет прежнее. Приглашение владельца из платформенного провижининга доходит тем же письмом. По ссылке /join предпросмотр говорит, есть ли учётная запись: существующая идёт на вход и принимает приглашение, новая задаёт имя и пароль (регистрация с теми же правилами, что в мастере) и активирует организацию. Ожидающие приглашения отдаются в списке сотрудников блоком invitations с действиями «отправить ещё раз» и «отозвать». Ответ на ошибки формы полями переехал из мастера в auth/common — им пользуется и регистрация. ADR-CHATBALLS-0047. Co-Authored-By: Claude Fable 5.1 --- apps/backend/chatballs/i18n/messages/en.py | 6 + apps/backend/chatballs/i18n/messages/ru.py | 6 + .../chatballs/identity/audit_catalog.py | 3 + .../chatballs/identity/auth/__init__.py | 8 +- .../chatballs/identity/auth/invitations.py | 88 ++++++- apps/backend/chatballs/identity/auth_urls.py | 2 + apps/backend/chatballs/identity/emails.py | 35 +++ .../identity/employee_invitations.py | 159 +++++++++++++ .../chatballs/identity/employee_urls.py | 4 + .../chatballs/identity/employee_views.py | 36 ++- .../chatballs/identity/event_handlers.py | 43 +++- .../chatballs/identity/invitation_models.py | 6 + .../chatballs/identity/invitation_service.py | 179 +++++++++++++-- .../0037_invitation_membership_fields.py | 29 +++ .../backend/chatballs/identity/setup_views.py | 20 +- .../identity/test_employee_invitation.py | 215 ++++++++++++++++++ .../identity/test_owner_invitation.py | 135 +++++++++++ 17 files changed, 929 insertions(+), 45 deletions(-) create mode 100644 apps/backend/chatballs/identity/employee_invitations.py create mode 100644 apps/backend/chatballs/identity/migrations/0037_invitation_membership_fields.py create mode 100644 apps/backend/chatballs/identity/test_employee_invitation.py create mode 100644 apps/backend/chatballs/identity/test_owner_invitation.py diff --git a/apps/backend/chatballs/i18n/messages/en.py b/apps/backend/chatballs/i18n/messages/en.py index c4cdd3e..74d4d96 100644 --- a/apps/backend/chatballs/i18n/messages/en.py +++ b/apps/backend/chatballs/i18n/messages/en.py @@ -82,6 +82,8 @@ MESSAGES: dict[str, object] = { "emails.initial_access_subject": "Your Chatballs access", "emails.password_reset_body": "Hello {name},\n\nYou asked to reset your Chatballs password. To set a new one, follow this link:\n{url}\n\nThe link is valid for 30 minutes. If you did not ask for a reset, simply ignore this email.", "emails.password_reset_subject": "Chatballs access recovery", + "emails.membership_invitation_subject": "Invitation to {organization}", + "emails.membership_invitation_body": "Hello, {name}.\n\nYou are invited to join the organization \u201c{organization}\u201d in Chatballs. To accept the invitation, follow this link:\n{url}\n\nThe link is valid for 7 days. If you did not expect this e-mail, simply ignore it.", "identity.link_invalid_or_expired": "The link is not valid or has expired", "identity.password_needs_digit": "The password must contain a digit.", "identity.password_needs_letter": "The password must contain a letter.", @@ -208,6 +210,9 @@ MESSAGES: dict[str, object] = { "audit.action_identity_avatar_updated": "Profile photo changed", "audit.action_identity_employee_blocked": "Operator blocked", "audit.action_identity_employee_created": "Operator added", + "audit.action_identity_employee_invited": "Operator invited", + "audit.action_identity_invitation_accepted": "Organization invitation accepted", + "audit.action_identity_invitation_revoked": "Operator invitation revoked", "audit.action_identity_employee_groups_changed": "Operator groups changed", "audit.action_identity_employee_password_reset": "Operator password reset", "audit.action_identity_employee_privileged_action_denied": "Privileged action denied", @@ -327,6 +332,7 @@ MESSAGES: dict[str, object] = { "identity.invalid_totp_code": "Invalid TOTP code", "identity.invitation_email_mismatch": "Invitation email does not match the account", "identity.invitation_invalid": "Invitation is invalid or has expired", + "identity.invitation_account_exists": "An account with this address already exists. Sign in with it", "identity.role_conflict": "User already has a different role in this organization", "identity.token_required": "token is required", "identity.totp_challenge_inactive": "TOTP challenge is not active", diff --git a/apps/backend/chatballs/i18n/messages/ru.py b/apps/backend/chatballs/i18n/messages/ru.py index 8e66b11..2752eeb 100644 --- a/apps/backend/chatballs/i18n/messages/ru.py +++ b/apps/backend/chatballs/i18n/messages/ru.py @@ -86,6 +86,8 @@ MESSAGES: dict[str, object] = { "emails.initial_access_subject": "Первичный доступ к Chatballs", "emails.password_reset_body": "Здравствуйте, {name}.\n\nВы запросили сброс пароля для Chatballs. Чтобы задать новый пароль, перейдите по ссылке:\n{url}\n\nСсылка действует 30 минут. Если вы не запрашивали сброс, просто проигнорируйте это письмо.", "emails.password_reset_subject": "Восстановление доступа к Chatballs", + "emails.membership_invitation_subject": "Приглашение в организацию {organization}", + "emails.membership_invitation_body": "Здравствуйте, {name}.\n\nВас приглашают в организацию «{organization}» в Chatballs. Чтобы принять приглашение, перейдите по ссылке:\n{url}\n\nСсылка действует 7 дней. Если вы не ожидали это письмо, просто проигнорируйте его.", "identity.link_invalid_or_expired": "Ссылка недействительна или истекла", "identity.password_needs_digit": "Пароль должен содержать цифру.", "identity.password_needs_letter": "Пароль должен содержать букву.", @@ -212,6 +214,9 @@ MESSAGES: dict[str, object] = { "audit.action_identity_avatar_updated": "Изменено фото профиля", "audit.action_identity_employee_blocked": "Сотрудник заблокирован", "audit.action_identity_employee_created": "Добавлен сотрудник", + "audit.action_identity_employee_invited": "Отправлено приглашение сотруднику", + "audit.action_identity_invitation_accepted": "Принято приглашение в организацию", + "audit.action_identity_invitation_revoked": "Отозвано приглашение сотрудника", "audit.action_identity_employee_groups_changed": "Изменены группы сотрудника", "audit.action_identity_employee_password_reset": "Сброшен пароль сотрудника", "audit.action_identity_employee_privileged_action_denied": "Отказано в привилегированном действии", @@ -331,6 +336,7 @@ MESSAGES: dict[str, object] = { "identity.invalid_totp_code": "Неверный код", "identity.invitation_email_mismatch": "Приглашение выписано на другой адрес", "identity.invitation_invalid": "Приглашение недействительно или истекло", + "identity.invitation_account_exists": "Учётная запись с этим адресом уже есть — войдите под ней", "identity.role_conflict": "У пользователя уже другая роль в этой организации", "identity.token_required": "Нужен токен", "identity.totp_challenge_inactive": "Проверка кода уже неактуальна — войдите заново", diff --git a/apps/backend/chatballs/identity/audit_catalog.py b/apps/backend/chatballs/identity/audit_catalog.py index f65d858..a039d0e 100644 --- a/apps/backend/chatballs/identity/audit_catalog.py +++ b/apps/backend/chatballs/identity/audit_catalog.py @@ -78,6 +78,9 @@ AUDIT_ACTION_LABELS: dict[str, str] = { "identity.avatar_updated": "audit.action_identity_avatar_updated", "identity.avatar_deleted": "audit.action_identity_avatar_deleted", "identity.employee_created": "audit.action_identity_employee_created", + "identity.employee_invited": "audit.action_identity_employee_invited", + "identity.invitation_accepted": "audit.action_identity_invitation_accepted", + "identity.invitation_revoked": "audit.action_identity_invitation_revoked", "identity.employee_updated": "audit.action_identity_employee_updated", "identity.employee_blocked": "audit.action_identity_employee_blocked", "identity.employee_unblocked": "audit.action_identity_employee_unblocked", diff --git a/apps/backend/chatballs/identity/auth/__init__.py b/apps/backend/chatballs/identity/auth/__init__.py index bd72885..0cd8493 100644 --- a/apps/backend/chatballs/identity/auth/__init__.py +++ b/apps/backend/chatballs/identity/auth/__init__.py @@ -1,4 +1,8 @@ -from chatballs.identity.auth.invitations import InvitationAcceptView +from chatballs.identity.auth.invitations import ( + InvitationAcceptView, + InvitationPreviewView, + InvitationRegisterView, +) from chatballs.identity.auth.password_reset import ( PasswordResetConfirmView, PasswordResetRequestView, @@ -40,4 +44,6 @@ __all__ = [ "TotpConfirmView", "TotpVerifyView", "InvitationAcceptView", + "InvitationPreviewView", + "InvitationRegisterView", ] diff --git a/apps/backend/chatballs/identity/auth/invitations.py b/apps/backend/chatballs/identity/auth/invitations.py index c9d36c2..f48b938 100644 --- a/apps/backend/chatballs/identity/auth/invitations.py +++ b/apps/backend/chatballs/identity/auth/invitations.py @@ -1,17 +1,87 @@ from __future__ import annotations -from rest_framework.permissions import IsAuthenticated +from django.contrib.auth import login +from django.core.exceptions import ValidationError +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_protect, ensure_csrf_cookie +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response +from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from chatballs.i18n import t -from chatballs.identity.auth.common import _user_payload -from chatballs.identity.invitation_service import InvitationError, accept_invitation +from chatballs.identity.auth.common import _user_payload, validation_response +from chatballs.identity.invitation_service import ( + InvitationError, + accept_invitation, + invitation_preview, + register_and_accept, +) +from chatballs.identity.sessions import remember_device + + +@method_decorator(ensure_csrf_cookie, name="dispatch") +class InvitationPreviewView(APIView): + """Что стоит за ссылкой /join до входа: организация, адрес, есть ли учётная запись. + + Ответ нужен экрану, чтобы решить, показать вход или форму создания пароля. + Токен — секрет из письма, поэтому подробности отдаются только по нему. + """ + + authentication_classes: list = [] + permission_classes = [AllowAny] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "login" + + def get(self, request: Request) -> Response: + token = str(request.query_params.get("token", "")).strip() + preview = invitation_preview(token) if token else None + if preview is None: + return Response({"valid": False}) + return Response({"valid": True, **preview}) + + +@method_decorator(csrf_protect, name="dispatch") +class InvitationRegisterView(APIView): + """Создать учётную запись по приглашению, принять его и войти.""" + + authentication_classes: list = [] + permission_classes = [AllowAny] + throttle_classes = [ScopedRateThrottle] + throttle_scope = "login" + + def post(self, request: Request) -> Response: + body = request.data if isinstance(request.data, dict) else {} + token = str(body.get("token", "")).strip() + if not token: + return Response({"detail": t("identity.token_required")}, status=400) + try: + accepted = register_and_accept( + token=token, + full_name=str(body.get("fullName", "")), + password=str(body.get("password", "")), + ) + except InvitationError as error: + return Response({"detail": str(error), "code": error.code}, status=400) + except ValidationError as error: + return validation_response(error) + user = accepted.membership.user + user.backend = "django.contrib.auth.backends.ModelBackend" + login(request, user) + remember_device(request) + return Response( + { + "authenticated": True, + "user": _user_payload(user), + "organizationPublicId": str(accepted.organization.public_id), + }, + status=201, + ) class InvitationAcceptView(APIView): - """Accept an OWNER invitation (SPEC-HUB-0021 §8.2). + """Accept an organization invitation: OWNER (SPEC-HUB-0021 §8.2) or employee. Authenticated endpoint: the caller must already have a HumanUser account (created through sign-up / password setup). The token is read from the body; @@ -26,7 +96,13 @@ class InvitationAcceptView(APIView): if not token: return Response({"detail": t("identity.token_required")}, status=400) try: - accept_invitation(token=token, user=request.user) + accepted = accept_invitation(token=token, user=request.user) except InvitationError as error: return Response({"detail": str(error)}, status=400) - return Response({"user": _user_payload(request.user)}) + return Response( + { + "user": _user_payload(request.user), + # Куда открыть приложение после принятия. + "organizationPublicId": str(accepted.organization.public_id), + } + ) diff --git a/apps/backend/chatballs/identity/auth_urls.py b/apps/backend/chatballs/identity/auth_urls.py index c75fe46..7cd53d2 100644 --- a/apps/backend/chatballs/identity/auth_urls.py +++ b/apps/backend/chatballs/identity/auth_urls.py @@ -23,4 +23,6 @@ urlpatterns = [ path("totp/confirm/", auth.TotpConfirmView.as_view(), name="auth-totp-confirm"), path("totp/verify/", auth.TotpVerifyView.as_view(), name="auth-totp-verify"), path("invitations/accept/", auth.InvitationAcceptView.as_view(), name="auth-invitation-accept"), + path("invitations/preview/", auth.InvitationPreviewView.as_view(), name="auth-invitation-preview"), + path("invitations/register/", auth.InvitationRegisterView.as_view(), name="auth-invitation-register"), ] diff --git a/apps/backend/chatballs/identity/emails.py b/apps/backend/chatballs/identity/emails.py index 1a63c29..f7b967b 100644 --- a/apps/backend/chatballs/identity/emails.py +++ b/apps/backend/chatballs/identity/emails.py @@ -12,6 +12,7 @@ from chatballs.identity.instance_settings import ( email_from_address, public_base_url, ) +from chatballs.identity.invitation_models import OrganizationInvitation from chatballs.identity.models import HumanUser @@ -60,6 +61,40 @@ def send_initial_access_email(user: HumanUser) -> None: ) +def send_membership_invitation_email( + invitation: OrganizationInvitation, token: str, user: HumanUser | None = None +) -> None: + """Приглашение в организацию по ссылке /join. + + Существующая учётная запись входит под своим паролем, новая создаёт его + по той же ссылке. Язык — получателя, если он известен, дальше + приглашающей организации. + """ + + join_url = f"{public_base_url()}/join?token={token}" + language = resolve_language( + user_language=user.ui_language if user else "", + organization_language=invitation.organization.language, + instance_language=default_language(), + ) + with translation.override(language): + send_mail( + subject=t( + "emails.membership_invitation_subject", + organization=invitation.organization.name, + ), + message=t( + "emails.membership_invitation_body", + name=(user.full_name if user else "") or invitation.email, + organization=invitation.organization.name, + url=join_url, + ), + from_email=email_from_address(), + recipient_list=[invitation.email], + connection=email_connection(), + ) + + def send_password_reset_email(user: HumanUser) -> None: reset_url = _password_setup_url(user) with translation.override(_recipient_language(user)): diff --git a/apps/backend/chatballs/identity/employee_invitations.py b/apps/backend/chatballs/identity/employee_invitations.py new file mode 100644 index 0000000..e81da78 --- /dev/null +++ b/apps/backend/chatballs/identity/employee_invitations.py @@ -0,0 +1,159 @@ +"""Ожидающие приглашения в списке сотрудников (кадры E1/E2, статус «Приглашён»). + +Приглашение существующей учётной записи ещё не членство, но администратор +должен его видеть там же, где сотрудников: строкой с той же ролью, должностью +и группами, что придут после принятия. Отсюда же приглашение отправляют ещё +раз или отзывают. +""" + +from __future__ import annotations + +from django.utils import timezone +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from chatballs.events.services import DomainEvent, enqueue_event +from chatballs.i18n import t +from chatballs.identity.audit import record_audit_event +from chatballs.identity.avatars import user_avatar_url +from chatballs.identity.employee_selectors import ROLE_ANY +from chatballs.identity.governance import can_create_role +from chatballs.identity.group_models import EmployeeGroup +from chatballs.identity.invitation_models import OrganizationInvitation +from chatballs.identity.invitation_service import ( + MEMBERSHIP_INVITATION_REQUESTED, + MEMBERSHIP_INVITATION_TTL, +) +from chatballs.identity.models import HumanUser, Organization, OrganizationMembership +from chatballs.identity.policy import has_capability_any_scope + + +def _pending(organization_id: int): + return OrganizationInvitation.objects.filter( + organization_id=organization_id, + accepted_at__isnull=True, + revoked_at__isnull=True, + expires_at__gt=timezone.now(), + ) + + +def pending_invitations_payload(organization: Organization, params) -> list[dict[str, object]]: + """Строки приглашений с теми же фильтрами, что у списка сотрудников.""" + + invitations = list(_pending(organization.id).order_by("email")) + if not invitations: + return [] + users = { + user.email.lower(): user + for user in HumanUser.objects.filter( + email__in=[invitation.email for invitation in invitations] + ) + } + group_ids = {gid for invitation in invitations for gid in invitation.group_ids} + groups = { + group.id: group + for group in EmployeeGroup.objects.filter(organization=organization, id__in=group_ids) + } + role = params.get("role") + group_filter = params.get("group") + query = params.get("q", "").strip().lower() + rows: list[dict[str, object]] = [] + for invitation in invitations: + user = users.get(invitation.email.lower()) + if user is None: + continue + if role and role != ROLE_ANY and invitation.role != role: + continue + if group_filter and group_filter != ROLE_ANY and str(group_filter).isdigit(): + if int(group_filter) not in invitation.group_ids: + continue + haystack = f"{user.full_name} {user.email} {invitation.position_title}".lower() + if query and query not in haystack: + continue + rows.append( + { + "id": invitation.id, + "email": user.email, + "fullName": user.full_name, + "avatarUrl": user_avatar_url(user, organization.public_id), + "role": invitation.role, + "positionTitle": invitation.position_title, + "phone": invitation.phone, + "groups": [ + {"id": gid, "name": groups[gid].name} + for gid in invitation.group_ids + if gid in groups + ], + "invitedAt": invitation.created_at.isoformat(), + "expiresAt": invitation.expires_at.isoformat(), + } + ) + return rows + + +def _load_for_action(request: Request, invitation_id: int) -> OrganizationInvitation | Response: + actor: OrganizationMembership = request.tenant_context.membership + if not has_capability_any_scope(actor, "employees.manage"): + return Response({"detail": t("admin.not_allowed")}, status=403) + invitation = _pending(actor.organization_id).filter(pk=invitation_id).first() + if invitation is None: + return Response({"detail": t("identity.invitation_invalid")}, status=404) + # Приглашение с ролью, которую актор выдать не вправе, ему и не отозвать. + if not can_create_role(actor, invitation.role): + return Response({"detail": t("admin.not_allowed")}, status=403) + return invitation + + +class InvitationResendView(APIView): + """Отправить письмо ещё раз: срок продлевается, токен выпускает воркер.""" + + permission_classes = [IsAuthenticated] + + def post(self, request: Request, invitation_id: int) -> Response: + loaded = _load_for_action(request, invitation_id) + if isinstance(loaded, Response): + return loaded + loaded.expires_at = timezone.now() + MEMBERSHIP_INVITATION_TTL + loaded.save(update_fields=["expires_at"]) + record_audit_event( + action="identity.employee_invited", + actor=request.user, + organization=loaded.organization, + object_type="OrganizationInvitation", + object_id=str(loaded.id), + payload={"role": loaded.role, "resend": True}, + request=request, + ) + enqueue_event( + DomainEvent( + aggregate_type="OrganizationInvitation", + aggregate_id=str(loaded.id), + event_type=MEMBERSHIP_INVITATION_REQUESTED, + payload={"invitationId": loaded.id}, + tenant_context=request.tenant_context, + ) + ) + return Response({"ok": True}) + + +class InvitationRevokeView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request: Request, invitation_id: int) -> Response: + loaded = _load_for_action(request, invitation_id) + if isinstance(loaded, Response): + return loaded + loaded.revoked_at = timezone.now() + loaded.save(update_fields=["revoked_at"]) + record_audit_event( + action="identity.invitation_revoked", + actor=request.user, + organization=loaded.organization, + object_type="OrganizationInvitation", + object_id=str(loaded.id), + payload={"role": loaded.role}, + request=request, + ) + return Response({"ok": True}) diff --git a/apps/backend/chatballs/identity/employee_urls.py b/apps/backend/chatballs/identity/employee_urls.py index e71f726..190a17b 100644 --- a/apps/backend/chatballs/identity/employee_urls.py +++ b/apps/backend/chatballs/identity/employee_urls.py @@ -1,6 +1,7 @@ from django.urls import path from chatballs.identity import ( + employee_invitations, employee_security_views, employee_views, ownership_views, @@ -11,6 +12,9 @@ urlpatterns = [ # Создание сотрудника (ADMIN/EMPLOYEE по policy). Путь operators/ сохранён для # обратной совместимости; поле role в теле выбирает системную роль. path("operators/", employee_views.EmployeeCreateView.as_view(), name="employee-create"), + # Ожидающие приглашения существующих учётных записей (статус «Приглашён»). + path("invitations//resend/", employee_invitations.InvitationResendView.as_view(), name="employee-invitation-resend"), + path("invitations//revoke/", employee_invitations.InvitationRevokeView.as_view(), name="employee-invitation-revoke"), path("/", employee_views.EmployeeDetailView.as_view(), name="employee-detail"), path("/avatar/", employee_views.EmployeeAvatarView.as_view(), name="employee-avatar"), path("/update/", employee_views.EmployeeUpdateView.as_view(), name="employee-update"), diff --git a/apps/backend/chatballs/identity/employee_views.py b/apps/backend/chatballs/identity/employee_views.py index b40fa6e..4bc8bce 100644 --- a/apps/backend/chatballs/identity/employee_views.py +++ b/apps/backend/chatballs/identity/employee_views.py @@ -9,6 +9,7 @@ from chatballs.api.pagination import page_payload, paginate from chatballs.events.services import DomainEvent, enqueue_event from chatballs.i18n import t from chatballs.identity.audit import record_audit_event +from chatballs.identity.employee_invitations import pending_invitations_payload from chatballs.identity.employee_password import clean_password_mode, issue_initial_password from chatballs.identity.employee_selectors import employees_for from chatballs.identity.employee_support import employee_payload, get_owned_profile @@ -21,6 +22,7 @@ from chatballs.identity.employee_validation import ( from chatballs.identity.event_handlers import INITIAL_ACCESS_REQUESTED from chatballs.identity.governance import EmployeeAction, can_create_role, can_manage_employee from chatballs.identity.group_models import EmployeeGroupMember +from chatballs.identity.invitation_service import invite_existing_user from chatballs.identity.models import EmployeeRole, HumanUser, OrganizationMembership from chatballs.identity.policy import has_capability_any_scope @@ -72,7 +74,13 @@ class EmployeeListView(APIView): employees_for(actor.organization_id, request.query_params), request.query_params, ) - return Response(page_payload(page, lambda employee: employee_payload(employee, actor))) + payload = page_payload(page, lambda employee: employee_payload(employee, actor)) + # Ожидающие приглашения — строками со статусом «Приглашён»: их мало, + # они не листаются и показываются на первой странице. + payload["invitations"] = pending_invitations_payload( + actor.organization, request.query_params + ) + return Response(payload) class EmployeeCreateView(APIView): @@ -107,13 +115,33 @@ class EmployeeCreateView(APIView): return Response({"detail": t("admin.temporary_passwords_unsupported")}, status=400) if password_mode is None: return Response({"detail": t("admin.unknown_password_mode")}, status=400) - if HumanUser.objects.filter(email=email).exists(): - return Response({"detail": t("admin.email_taken")}, status=400) - groups, groups_error = resolve_groups(actor.organization, body.get("groupIds")) if groups_error: return Response({"detail": groups_error}, status=400) + existing = HumanUser.objects.filter(email=email).first() + if existing is not None: + # Учётная запись глобальная, а вход в организацию — по согласию + # человека: вместо «e-mail занят» уходит приглашение с той же ролью + # и должностью, членство появится после его принятия. В своей + # организации адрес и правда занят. + if not existing.is_active or OrganizationMembership.objects.filter( + user=existing, organization=actor.organization + ).exists(): + return Response({"detail": t("admin.email_taken")}, status=400) + invite_existing_user( + organization=actor.organization, + user=existing, + role=requested_role, + position_title=position_title, + phone=phone, + groups=groups or [], + created_by=actor, + context=request.tenant_context, + request=request, + ) + return Response({"employee": None, "password": None, "invited": True}, status=201) + user = HumanUser.objects.create_user( email=email, password=None, diff --git a/apps/backend/chatballs/identity/event_handlers.py b/apps/backend/chatballs/identity/event_handlers.py index 78064f1..3415e16 100644 --- a/apps/backend/chatballs/identity/event_handlers.py +++ b/apps/backend/chatballs/identity/event_handlers.py @@ -1,5 +1,15 @@ from chatballs.events.handlers import register -from chatballs.identity.emails import send_initial_access_email, send_password_reset_email +from chatballs.identity.emails import ( + send_initial_access_email, + send_membership_invitation_email, + send_password_reset_email, +) +from chatballs.identity.invitation_models import OrganizationInvitation +from chatballs.identity.invitation_service import ( + MEMBERSHIP_INVITATION_REQUESTED, + OWNER_INVITATION_REQUESTED, + refresh_invitation_token, +) from chatballs.identity.models import HumanUser from chatballs.tenancy.context import TenantContext @@ -39,6 +49,37 @@ def handle_password_reset_requested(payload: dict, context: TenantContext | None send_password_reset_email(user) +def _send_invitation(payload: dict, context: TenantContext | None, *, require_account: bool) -> None: + if context is None: + raise ValueError("Invitation is a tenant event") + invitation = ( + OrganizationInvitation.objects.select_related("organization") + .filter(pk=payload.get("invitationId"), organization_id=context.organization_id) + .first() + ) + if invitation is None or not invitation.is_pending: + return + user = HumanUser.objects.filter(email__iexact=invitation.email, is_active=True).first() + if user is None and require_account: + return + # Токен выпускается здесь, в момент отправки: открытый токен нигде не + # хранится, а письмо уходит позже, чем приглашение выписано. + token = refresh_invitation_token(invitation) + send_membership_invitation_email(invitation, token, user) + + +@register(MEMBERSHIP_INVITATION_REQUESTED) +def handle_membership_invitation_requested(payload: dict, context: TenantContext | None) -> None: + # Сотрудника приглашают только с существующей учётной записью. + _send_invitation(payload, context, require_account=True) + + +@register(OWNER_INVITATION_REQUESTED) +def handle_owner_invitation_requested(payload: dict, context: TenantContext | None) -> None: + # Владельца из провижининга учётная запись может ждать: он создаст её по ссылке. + _send_invitation(payload, context, require_account=False) + + # --- Демо-данные (мастер первого запуска и «Настройки») ----------------------- DEMO_INSTALL_REQUESTED = "demo.install_requested" diff --git a/apps/backend/chatballs/identity/invitation_models.py b/apps/backend/chatballs/identity/invitation_models.py index b370678..253ab5c 100644 --- a/apps/backend/chatballs/identity/invitation_models.py +++ b/apps/backend/chatballs/identity/invitation_models.py @@ -19,6 +19,12 @@ class OrganizationInvitation(models.Model): ) email = models.EmailField() role = models.CharField(max_length=32, choices=EmployeeRole.choices) + # Поля будущего членства: приглашение существующего пользователя несёт + # то же, что форма создания сотрудника, а членство собирается из них при + # принятии. Группы — по id: к моменту принятия часть могла исчезнуть. + position_title = models.CharField(max_length=120, blank=True, default="") + phone = models.CharField(max_length=32, blank=True, default="") + group_ids = models.JSONField(default=list, blank=True) token_hash = models.CharField(max_length=128, unique=True) expires_at = models.DateTimeField() created_by = models.ForeignKey( diff --git a/apps/backend/chatballs/identity/invitation_service.py b/apps/backend/chatballs/identity/invitation_service.py index 0de2593..644f8a2 100644 --- a/apps/backend/chatballs/identity/invitation_service.py +++ b/apps/backend/chatballs/identity/invitation_service.py @@ -3,13 +3,18 @@ from __future__ import annotations import hashlib import secrets from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError from django.db import transaction from django.utils import timezone +from chatballs.events.services import DomainEvent, enqueue_event from chatballs.i18n import t +from chatballs.i18n.audience import customer_language from chatballs.identity.audit import record_audit_event +from chatballs.identity.group_models import EmployeeGroup, EmployeeGroupMember from chatballs.identity.models import ( EmployeeRole, HumanUser, @@ -18,8 +23,17 @@ from chatballs.identity.models import ( OrganizationMembership, OrganizationStatus, ) +from chatballs.tenancy.context import TenantContext from chatballs.tenancy.database import tenant_atomic +# Приглашение существующего пользователя в организацию: письмо отправляет +# воркер по этому событию (identity.event_handlers). +MEMBERSHIP_INVITATION_REQUESTED = "identity.membership_invitation_requested" +# Приглашение владельца из платформенного провижининга (SPEC-HUB-0021 §8.2): +# учётной записи может ещё не быть, тогда человек создаёт её по ссылке. +OWNER_INVITATION_REQUESTED = "organization.owner_invitation_requested" +MEMBERSHIP_INVITATION_TTL = timedelta(days=7) + def _token_hash(token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() @@ -54,15 +68,19 @@ def issue_invitation( role: str, expires_at: datetime, created_by: OrganizationMembership | None, + position_title: str = "", + phone: str = "", + group_ids: list[int] | None = None, ) -> IssuedInvitation: normalized_email = email.strip().lower() now = timezone.now() + # Повторное приглашение на тот же адрес заменяет прежнее: старое письмо + # перестаёт работать, а не живёт параллельно с новым. OrganizationInvitation.objects.filter( organization=organization, email__iexact=normalized_email, accepted_at__isnull=True, revoked_at__isnull=True, - expires_at__lte=now, ).update(revoked_at=now) token = secrets.token_urlsafe(32) invitation = OrganizationInvitation.objects.create( @@ -72,10 +90,119 @@ def issue_invitation( token_hash=_token_hash(token), expires_at=expires_at, created_by=created_by, + position_title=position_title, + phone=phone, + group_ids=list(group_ids or []), ) return IssuedInvitation(invitation=invitation, token=token) +def refresh_invitation_token(invitation: OrganizationInvitation) -> str: + """Новый токен взамен прежнего. + + Открытый токен нигде не хранится, а письмо уходит из воркера позже, чем + приглашение выписано, — поэтому воркер выпускает токен сам, в момент + отправки, и старый перестаёт действовать. + """ + + token = secrets.token_urlsafe(32) + invitation.token_hash = _token_hash(token) + invitation.save(update_fields=["token_hash"]) + return token + + +def invite_existing_user( + *, + organization: Organization, + user: HumanUser, + role: str, + position_title: str, + phone: str, + groups: list[EmployeeGroup], + created_by: OrganizationMembership | None, + context: TenantContext, + request=None, +) -> OrganizationInvitation: + """Пригласить уже существующую учётную запись в организацию. + + Учётная запись глобальная, а членство появляется только с согласия + человека: он получает письмо и принимает приглашение под своим входом. + """ + + issued = issue_invitation( + organization=organization, + email=user.email, + role=role, + expires_at=timezone.now() + MEMBERSHIP_INVITATION_TTL, + created_by=created_by, + position_title=position_title, + phone=phone, + group_ids=[group.id for group in groups], + ) + record_audit_event( + action="identity.employee_invited", + actor=context.actor_user, + organization=organization, + object_type="OrganizationInvitation", + object_id=str(issued.invitation.id), + payload={"role": role}, + request=request, + ) + enqueue_event( + DomainEvent( + aggregate_type="OrganizationInvitation", + aggregate_id=str(issued.invitation.id), + event_type=MEMBERSHIP_INVITATION_REQUESTED, + payload={"invitationId": issued.invitation.id}, + tenant_context=context, + ) + ) + return issued.invitation + + +def invitation_preview(token: str) -> dict[str, object] | None: + """Что видит человек по ссылке до входа: куда зовут и есть ли учётная запись.""" + + invitation = pending_invitation_for_token(token) + if invitation is None: + return None + return { + "email": invitation.email, + "organizationName": invitation.organization.name, + "accountExists": HumanUser.objects.filter(email__iexact=invitation.email).exists(), + } + + +@transaction.atomic +def register_and_accept(*, token: str, full_name: str, password: str) -> AcceptedInvitation: + """Создать учётную запись по приглашению и сразу принять его. + + Только для адреса без учётной записи: у существующей есть пароль, и + приглашение принимается после входа. Пароль проверяется теми же + правилами, что в мастере первого запуска. + """ + + invitation = pending_invitation_for_token(token) + if invitation is None: + raise InvitationError(t("identity.invitation_invalid"), code="invitation_invalid") + if HumanUser.objects.filter(email__iexact=invitation.email).exists(): + raise InvitationError(t("identity.invitation_account_exists"), code="account_exists") + name = " ".join(full_name.split()) + if not name: + raise ValidationError({"fullName": t("setup.your_name_required")}) + probe = HumanUser(email=invitation.email, full_name=name) + validate_password(password, user=probe) + user = HumanUser.objects.create_user( + email=invitation.email, + password=password, + full_name=name, + is_staff=False, + is_superuser=False, + must_change_password=False, + ) + return accept_invitation(token=token, user=user) + + def pending_invitation_for_token(token: str) -> OrganizationInvitation | None: if not token: return None @@ -89,10 +216,11 @@ def pending_invitation_for_token(token: str) -> OrganizationInvitation | None: @transaction.atomic def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation: - """Accept an OWNER invitation and activate the organization (SPEC-HUB-0021 §8.2). + """Принять приглашение: владельца (SPEC-HUB-0021 §8.2) или сотрудника. - Idempotent: re-accepting the same token does not create a second membership - or usage period. Requires an authenticated HumanUser with a matching email. + Членство собирается из полей приглашения; для владельца организация ещё + и активируется. Идемпотентно: повторное принятие того же токена не создаёт + второго членства. Нужна учётная запись с тем же e-mail. """ invitation = pending_invitation_for_token(token) if invitation is None: @@ -110,13 +238,17 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation: organization = invitation.organization with tenant_atomic(organization.id): - membership = _ensure_owner_membership(organization, user) + membership = _ensure_membership(organization, user, invitation) if invitation.role == EmployeeRole.OWNER: _activate_organization(organization, user, membership) invitation.accepted_at = timezone.now() invitation.save(update_fields=["accepted_at"]) record_audit_event( - action="organization.owner_activated", + action=( + "organization.owner_activated" + if invitation.role == EmployeeRole.OWNER + else "identity.invitation_accepted" + ), actor=user, organization=organization, object_type="OrganizationInvitation", @@ -128,25 +260,42 @@ def accept_invitation(*, token: str, user: HumanUser) -> AcceptedInvitation: ) -def _ensure_owner_membership( - organization: Organization, user: HumanUser +def _ensure_membership( + organization: Organization, user: HumanUser, invitation: OrganizationInvitation ) -> OrganizationMembership: - membership, _ = OrganizationMembership.objects.get_or_create( + position_title = invitation.position_title + if not position_title and invitation.role == EmployeeRole.OWNER: + position_title = t("setup.owner_position", language=customer_language(organization)) + membership, created = OrganizationMembership.objects.get_or_create( user=user, organization=organization, defaults={ - "role": EmployeeRole.OWNER, - "position_title": "Владелец", + "role": invitation.role, + "position_title": position_title, + "phone": invitation.phone, "totp_required": False, }, ) - if membership.role != EmployeeRole.OWNER: - # An existing non-OWNER membership for this user should not be silently - # promoted by an invitation; surface as a conflict instead. + if membership.role != invitation.role: + # Уже существующее членство с другой ролью приглашение молча не меняет: + # это конфликт, а не повышение. raise InvitationError( t("identity.role_conflict"), code="role_conflict", ) + if created and invitation.group_ids: + # Группы, которые к моменту принятия ещё существуют. + groups = EmployeeGroup.objects.filter( + organization=organization, id__in=list(invitation.group_ids) + ) + EmployeeGroupMember.objects.bulk_create( + [ + EmployeeGroupMember( + organization_id=organization.id, group=group, employee=membership + ) + for group in groups + ] + ) return membership diff --git a/apps/backend/chatballs/identity/migrations/0037_invitation_membership_fields.py b/apps/backend/chatballs/identity/migrations/0037_invitation_membership_fields.py new file mode 100644 index 0000000..b851499 --- /dev/null +++ b/apps/backend/chatballs/identity/migrations/0037_invitation_membership_fields.py @@ -0,0 +1,29 @@ +# Приглашение существующего пользователя во вторую организацию: вместо ошибки +# «e-mail занят» администратор выписывает приглашение с той же ролью, +# должностью, телефоном и группами, что и при создании сотрудника. Членство +# создаётся из этих полей в момент принятия, а не в момент приглашения. +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("identity", "0036_humanuser_is_instance_admin"), + ] + + operations = [ + migrations.AddField( + model_name="organizationinvitation", + name="position_title", + field=models.CharField(blank=True, default="", max_length=120), + ), + migrations.AddField( + model_name="organizationinvitation", + name="phone", + field=models.CharField(blank=True, default="", max_length=32), + ), + migrations.AddField( + model_name="organizationinvitation", + name="group_ids", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/apps/backend/chatballs/identity/setup_views.py b/apps/backend/chatballs/identity/setup_views.py index 8048a5e..20d5558 100644 --- a/apps/backend/chatballs/identity/setup_views.py +++ b/apps/backend/chatballs/identity/setup_views.py @@ -9,7 +9,7 @@ from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView from chatballs.i18n import t -from chatballs.identity.auth.common import _user_payload +from chatballs.identity.auth.common import _user_payload, validation_response from chatballs.identity.setup import ( SetupAlreadyCompleted, SetupInput, @@ -23,22 +23,6 @@ def setup_closed() -> dict[str, str]: return {"detail": t("identity.setup_already_done")} -def _validation_response(error: ValidationError) -> Response: - if hasattr(error, "message_dict"): - errors = { - key: messages[0] if isinstance(messages, list) else str(messages) - for key, messages in error.message_dict.items() - } - # validate_password кладёт сообщения без ключа поля. - if "__all__" in errors: - errors["password"] = " ".join(error.message_dict["__all__"]) - del errors["__all__"] - detail = next(iter(errors.values()), t("setup.check_fields")) - return Response({"detail": detail, "errors": errors}, status=400) - message = " ".join(error.messages) - return Response({"detail": message, "errors": {"password": message}}, status=400) - - @method_decorator(ensure_csrf_cookie, name="dispatch") class SetupStatusView(APIView): authentication_classes: list = [] @@ -75,7 +59,7 @@ class SetupView(APIView): except SetupAlreadyCompleted: return Response(setup_closed(), status=409) except ValidationError as error: - return _validation_response(error) + return validation_response(error) owner = result.owner owner.backend = "django.contrib.auth.backends.ModelBackend" login(request, owner) diff --git a/apps/backend/chatballs/identity/test_employee_invitation.py b/apps/backend/chatballs/identity/test_employee_invitation.py new file mode 100644 index 0000000..0351e50 --- /dev/null +++ b/apps/backend/chatballs/identity/test_employee_invitation.py @@ -0,0 +1,215 @@ +"""Приглашение существующей учётной записи во вторую организацию. + +Учётная запись глобальная, членство — по согласию человека: создание +сотрудника с уже занятым e-mail выписывает приглашение, письмо уходит из +воркера с новым токеном, а членство появляется после принятия. +""" + +from __future__ import annotations + +from django.core import mail +from django.test import TestCase + +from chatballs.events.handlers import dispatch +from chatballs.events.models import OutboxEvent +from chatballs.identity.group_models import EmployeeGroup +from chatballs.identity.invitation_models import OrganizationInvitation +from chatballs.identity.invitation_service import MEMBERSHIP_INVITATION_REQUESTED +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + Organization, + OrganizationMembership, +) +from chatballs.testing import TenantAPIClient + +PASSWORD = "Long-and-strong-passphrase-42" + + +class InvitationTestBase(TestCase): + def setUp(self) -> None: + self.first = Organization.objects.create(name="First", slug="inv-first") + self.second = Organization.objects.create(name="Second", slug="inv-second") + self.second_owner = HumanUser.objects.create_user( + email="second-owner@example.test", password=PASSWORD, full_name="Second Owner" + ) + self.second_membership = OrganizationMembership.objects.create( + organization=self.second, + user=self.second_owner, + role=EmployeeRole.OWNER, + position_title="Owner", + ) + self.group = EmployeeGroup.objects.create(organization=self.second, name="Support") + # Человек уже работает в первой организации. + self.person = HumanUser.objects.create_user( + email="person@example.test", password=PASSWORD, full_name="Person" + ) + OrganizationMembership.objects.create( + organization=self.first, + user=self.person, + role=EmployeeRole.EMPLOYEE, + position_title="Operator", + ) + self.client = TenantAPIClient() + self.client.force_authenticate(self.second_owner) + self.client.set_tenant(self.second) + + def _create(self, email: str = "person@example.test"): + return self.client.post( + "/api/v1/employees/operators/", + { + "email": email, + "fullName": "Person", + "positionTitle": "Support operator", + "role": EmployeeRole.EMPLOYEE, + "passwordMode": "mail", + "groupIds": [self.group.id], + }, + format="json", + ) + + +class InviteExistingUserTests(InvitationTestBase): + def test_existing_account_gets_an_invitation_instead_of_an_error(self) -> None: + response = self._create() + + self.assertEqual(response.status_code, 201, response.content) + self.assertTrue(response.json()["invited"]) + self.assertIsNone(response.json()["employee"]) + self.assertFalse( + OrganizationMembership.objects.filter(user=self.person, organization=self.second).exists() + ) + invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test") + self.assertEqual(invitation.role, EmployeeRole.EMPLOYEE) + self.assertEqual(invitation.position_title, "Support operator") + self.assertEqual(invitation.group_ids, [self.group.id]) + self.assertTrue( + OutboxEvent.objects.filter( + event_type=MEMBERSHIP_INVITATION_REQUESTED, organization=self.second + ).exists() + ) + + def test_same_organization_still_reports_the_address_as_taken(self) -> None: + response = self._create(email="second-owner@example.test") + + self.assertEqual(response.status_code, 400) + self.assertFalse(OrganizationInvitation.objects.filter(organization=self.second).exists()) + + def test_worker_sends_the_letter_and_the_link_accepts(self) -> None: + self._create() + event = OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED) + + dispatch(event) + + self.assertEqual(len(mail.outbox), 1) + letter = mail.outbox[0] + self.assertEqual(letter.to, ["person@example.test"]) + self.assertIn("/join?token=", letter.body) + token = letter.body.split("/join?token=", 1)[1].split()[0] + + person_client = TenantAPIClient() + person_client.force_authenticate(self.person) + accepted = person_client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json") + + self.assertEqual(accepted.status_code, 200, accepted.content) + self.assertEqual(accepted.json()["organizationPublicId"], str(self.second.public_id)) + membership = OrganizationMembership.objects.get(user=self.person, organization=self.second) + self.assertEqual(membership.role, EmployeeRole.EMPLOYEE) + self.assertEqual(membership.position_title, "Support operator") + self.assertEqual([link.group_id for link in membership.group_links.all()], [self.group.id]) + self.assertEqual( + {item["organizationPublicId"] for item in accepted.json()["user"]["memberships"]}, + {str(self.first.public_id), str(self.second.public_id)}, + ) + + def test_reinvite_replaces_the_pending_invitation(self) -> None: + self._create() + first = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test") + + self._create() + + first.refresh_from_db() + self.assertIsNotNone(first.revoked_at) + self.assertEqual( + OrganizationInvitation.objects.filter( + organization=self.second, email="person@example.test", revoked_at__isnull=True + ).count(), + 1, + ) + + def test_stranger_cannot_use_someone_elses_invitation(self) -> None: + self._create() + dispatch(OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED)) + token = mail.outbox[0].body.split("/join?token=", 1)[1].split()[0] + stranger = HumanUser.objects.create_user(email="stranger@example.test", password=PASSWORD) + client = TenantAPIClient() + client.force_authenticate(stranger) + + response = client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json") + + self.assertEqual(response.status_code, 400) + self.assertFalse( + OrganizationMembership.objects.filter(user=stranger, organization=self.second).exists() + ) + + +class PendingInvitationRowsTests(InvitationTestBase): + """Ожидающие приглашения видны в списке сотрудников и управляются оттуда.""" + + def test_list_shows_the_invitation_with_role_position_and_groups(self) -> None: + self._create() + + payload = self.client.get("/api/v1/employees/").json() + + self.assertEqual(len(payload["invitations"]), 1) + row = payload["invitations"][0] + self.assertEqual(row["email"], "person@example.test") + self.assertEqual(row["fullName"], "Person") + self.assertEqual(row["role"], EmployeeRole.EMPLOYEE) + self.assertEqual(row["positionTitle"], "Support operator") + self.assertEqual([group["id"] for group in row["groups"]], [self.group.id]) + # Фильтры списка действуют и на приглашения. + self.assertEqual(self.client.get("/api/v1/employees/?role=ADMIN").json()["invitations"], []) + self.assertEqual(len(self.client.get("/api/v1/employees/?q=person").json()["invitations"]), 1) + self.assertEqual(self.client.get("/api/v1/employees/?q=nobody").json()["invitations"], []) + + def test_resend_extends_expiry_and_queues_a_new_letter(self) -> None: + self._create() + invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test") + OutboxEvent.objects.filter(event_type=MEMBERSHIP_INVITATION_REQUESTED).delete() + + response = self.client.post(f"/api/v1/employees/invitations/{invitation.id}/resend/") + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(OutboxEvent.objects.filter(event_type=MEMBERSHIP_INVITATION_REQUESTED).count(), 1) + refreshed = OrganizationInvitation.objects.get(pk=invitation.id) + self.assertGreaterEqual(refreshed.expires_at, invitation.expires_at) + + def test_revoke_hides_the_row_and_kills_the_link(self) -> None: + self._create() + dispatch(OutboxEvent.objects.get(event_type=MEMBERSHIP_INVITATION_REQUESTED)) + token = mail.outbox[0].body.split("/join?token=", 1)[1].split()[0] + invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test") + + response = self.client.post(f"/api/v1/employees/invitations/{invitation.id}/revoke/") + + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(self.client.get("/api/v1/employees/").json()["invitations"], []) + person_client = TenantAPIClient() + person_client.force_authenticate(self.person) + accepted = person_client.post("/api/v1/auth/invitations/accept/", {"token": token}, format="json") + self.assertEqual(accepted.status_code, 400) + + def test_employee_cannot_manage_invitations(self) -> None: + self._create() + invitation = OrganizationInvitation.objects.get(organization=self.second, email="person@example.test") + operator = HumanUser.objects.create_user(email="operator@example.test", password=PASSWORD) + OrganizationMembership.objects.create( + organization=self.second, user=operator, role=EmployeeRole.EMPLOYEE, position_title="Operator" + ) + client = TenantAPIClient() + client.force_authenticate(operator) + client.set_tenant(self.second) + + self.assertEqual(client.post(f"/api/v1/employees/invitations/{invitation.id}/revoke/").status_code, 403) + self.assertEqual(client.post(f"/api/v1/employees/invitations/{invitation.id}/resend/").status_code, 403) diff --git a/apps/backend/chatballs/identity/test_owner_invitation.py b/apps/backend/chatballs/identity/test_owner_invitation.py new file mode 100644 index 0000000..3c036c6 --- /dev/null +++ b/apps/backend/chatballs/identity/test_owner_invitation.py @@ -0,0 +1,135 @@ +"""Приглашение владельца из платформенного провижининга доходит до человека. + +Учётной записи может ещё не быть: письмо уходит из воркера, а по ссылке +человек задаёт имя и пароль, принимает приглашение и оказывается владельцем +активированной организации. Существующая учётная запись идёт на обычный вход. +""" + +from __future__ import annotations + +from django.core import mail +from django.test import TestCase, override_settings + +from chatballs.events.handlers import dispatch +from chatballs.events.models import OutboxEvent +from chatballs.identity.invitation_service import OWNER_INVITATION_REQUESTED +from chatballs.identity.models import ( + EmployeeRole, + HumanUser, + OrganizationMembership, + OrganizationStatus, +) +from chatballs.platform.provisioning_command import ProvisioningCommand +from chatballs.platform.provisioning_service import provision_organization +from chatballs.platform.testing import create_platform_operator +from chatballs.testing import TenantAPIClient + +PASSWORD = "Very-strong-passphrase-42" + + +@override_settings(ROOT_URLCONF="chatballs_backend.urls_app") +class OwnerInvitationFlowTests(TestCase): + def setUp(self) -> None: + self.operator, _ = create_platform_operator() + + def _provision(self, email: str = "new-owner@example.test"): + return provision_organization( + command=ProvisioningCommand( + organization_name="Fresh Co", + organization_slug="fresh-co", + owner_email=email, + source="PLATFORM_OPERATOR", + idempotency_key=f"idem-{email}", + ), + operator=self.operator, + ) + + def _token_from_mail(self) -> str: + return mail.outbox[-1].body.split("/join?token=", 1)[1].split()[0] + + def test_letter_goes_out_and_a_new_person_registers_as_owner(self) -> None: + result = self._provision() + dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED)) + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ["new-owner@example.test"]) + token = self._token_from_mail() + client = TenantAPIClient() + + preview = client.get(f"/api/v1/auth/invitations/preview/?token={token}").json() + self.assertEqual(preview["valid"], True) + self.assertEqual(preview["organizationName"], "Fresh Co") + self.assertFalse(preview["accountExists"]) + + response = client.post( + "/api/v1/auth/invitations/register/", + {"token": token, "fullName": "New Owner", "password": PASSWORD}, + format="json", + ) + + self.assertEqual(response.status_code, 201, response.content) + body = response.json() + self.assertEqual(body["organizationPublicId"], str(result.organization.public_id)) + self.assertEqual(body["user"]["email"], "new-owner@example.test") + self.assertFalse(body["user"]["isInstanceAdmin"]) + result.organization.refresh_from_db() + self.assertEqual(result.organization.status, OrganizationStatus.ACTIVE) + owner = HumanUser.objects.get(email="new-owner@example.test") + self.assertTrue(owner.check_password(PASSWORD)) + self.assertFalse(owner.must_change_password) + self.assertTrue( + OrganizationMembership.objects.filter( + organization=result.organization, user=owner, role=EmployeeRole.OWNER + ).exists() + ) + # Сессия установлена: приложение сразу открывается под владельцем. + self.assertTrue(client.get("/api/v1/auth/session/").json()["authenticated"]) + + def test_weak_password_and_empty_name_are_reported_by_field(self) -> None: + self._provision() + dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED)) + token = self._token_from_mail() + client = TenantAPIClient() + + weak = client.post( + "/api/v1/auth/invitations/register/", + {"token": token, "fullName": "New Owner", "password": "short"}, + format="json", + ) + nameless = client.post( + "/api/v1/auth/invitations/register/", + {"token": token, "fullName": " ", "password": PASSWORD}, + format="json", + ) + + self.assertEqual(weak.status_code, 400) + self.assertIn("password", weak.json()["errors"]) + self.assertEqual(nameless.status_code, 400) + self.assertIn("fullName", nameless.json()["errors"]) + self.assertFalse(HumanUser.objects.filter(email="new-owner@example.test").exists()) + + def test_existing_account_is_sent_to_login_and_cannot_register(self) -> None: + HumanUser.objects.create_user(email="known@example.test", password=PASSWORD, is_active=True) + # Активная учётная запись получает владение сразу, приглашение не нужно: + # проверяем ветку через приглашение сотрудника той же организации. + result = self._provision(email="known-owner@example.test") + dispatch(OutboxEvent.objects.get(event_type=OWNER_INVITATION_REQUESTED)) + token = self._token_from_mail() + HumanUser.objects.create_user(email="known-owner@example.test", password=PASSWORD) + client = TenantAPIClient() + + preview = client.get(f"/api/v1/auth/invitations/preview/?token={token}").json() + register = client.post( + "/api/v1/auth/invitations/register/", + {"token": token, "fullName": "Someone", "password": PASSWORD}, + format="json", + ) + + self.assertTrue(preview["accountExists"]) + self.assertEqual(register.status_code, 400) + self.assertEqual(register.json()["code"], "account_exists") + self.assertEqual(result.organization.status, OrganizationStatus.PENDING_OWNER) + + def test_unknown_token_previews_as_invalid(self) -> None: + response = TenantAPIClient().get("/api/v1/auth/invitations/preview/?token=nope") + self.assertEqual(response.json(), {"valid": False})