mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
✉️ feat(identity): приглашения в организацию — существующие учётные записи и владелец из провижининга
Учётная запись глобальная, а во вторую организацию было не попасть: форма создания сотрудника отвечала «e-mail занят». Теперь занятый адрес из другой организации получает приглашение с той же ролью, должностью, телефоном и группами; членство появляется, когда человек принял его под своим входом. Письмо отправляет воркер и выпускает токен в момент отправки — открытый токен нигде не хранится. Повторное приглашение заменяет прежнее. Приглашение владельца из платформенного провижининга доходит тем же письмом. По ссылке /join предпросмотр говорит, есть ли учётная запись: существующая идёт на вход и принимает приглашение, новая задаёт имя и пароль (регистрация с теми же правилами, что в мастере) и активирует организацию. Ожидающие приглашения отдаются в списке сотрудников блоком invitations с действиями «отправить ещё раз» и «отозвать». Ответ на ошибки формы полями переехал из мастера в auth/common — им пользуется и регистрация. ADR-CHATBALLS-0047. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
1390ccc426
commit
3e790af6e5
17 files changed
+929
-45
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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": "Проверка кода уже неактуальна — войдите заново",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)):
|
||||
|
||||
@@ -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})
|
||||
@@ -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/<int:invitation_id>/resend/", employee_invitations.InvitationResendView.as_view(), name="employee-invitation-resend"),
|
||||
path("invitations/<int:invitation_id>/revoke/", employee_invitations.InvitationRevokeView.as_view(), name="employee-invitation-revoke"),
|
||||
path("<int:user_id>/", employee_views.EmployeeDetailView.as_view(), name="employee-detail"),
|
||||
path("<int:user_id>/avatar/", employee_views.EmployeeAvatarView.as_view(), name="employee-avatar"),
|
||||
path("<int:user_id>/update/", employee_views.EmployeeUpdateView.as_view(), name="employee-update"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
Reference in new issue
Block a user